From ccac9f325c4aa8604ac18ca85fc98a2c81c1249d Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 18 Sep 2026 19:26:44 +0000 Subject: [PATCH 01/12] feat(badges): implement Track 2 PR merge contributor badge automation - Add declarative rules for the 8 authoritative project badges in badge-rules.json - Add pure evaluator module with glob path matching and exclusion guards - Add identity resolver verifying GitHub commit author and DCO trailers - Add CLI award orchestrator with tracking label deduplication - Add reusable GitHub Actions workflow award-project-badge.yml with PR-level concurrency and race-safe label handling - Add manual dry-run testing workflow test-badge-evaluator.yml - Add native Node.js unit tests for evaluator, resolver, and orchestrator Signed-off-by: Parth Gartan --- .github/workflows/award-project-badge.yml | 210 ++++++++++++++++++ .github/workflows/test-badge-evaluator.yml | 72 ++++++ package.json | 3 +- utils/award-orchestrator.js | 244 +++++++++++++++++++++ utils/award-orchestrator.test.js | 137 ++++++++++++ utils/badge-evaluator.js | 171 +++++++++++++++ utils/badge-evaluator.test.js | 196 +++++++++++++++++ utils/badge-rules.json | 177 +++++++++++++++ utils/identity-resolver.js | 135 ++++++++++++ utils/identity-resolver.test.js | 98 +++++++++ 10 files changed, 1442 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/award-project-badge.yml create mode 100644 .github/workflows/test-badge-evaluator.yml create mode 100644 utils/award-orchestrator.js create mode 100644 utils/award-orchestrator.test.js create mode 100644 utils/badge-evaluator.js create mode 100644 utils/badge-evaluator.test.js create mode 100644 utils/badge-rules.json create mode 100644 utils/identity-resolver.js create mode 100644 utils/identity-resolver.test.js diff --git a/.github/workflows/award-project-badge.yml b/.github/workflows/award-project-badge.yml new file mode 100644 index 0000000..ad60d0f --- /dev/null +++ b/.github/workflows/award-project-badge.yml @@ -0,0 +1,210 @@ +name: Award Project Contributor Badges + +on: + workflow_call: + inputs: + pr_number: + description: "Merged Pull Request number in the caller repository" + required: true + type: number + dry_run: + description: "Simulate badge evaluation and skip live awards/labels" + required: false + type: boolean + default: false + slack_channel: + description: "Slack channel ID for bot command dispatch" + required: false + type: string + default: "CLDRKJZ0T" + secrets: + SLACK_BOT_TOKEN: + description: "Slack Bot Token for /award-badge dispatches" + required: false + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: badge-award-${{ github.repository }}-${{ inputs.pr_number }} + cancel-in-progress: false + +jobs: + evaluate-and-award: + name: Evaluate and Award Badges + runs-on: ubuntu-latest + steps: + - name: Checkout trusted recognition engine + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA + with: + repository: ${{ job.workflow_repository || 'layer5io/recognition' }} + ref: ${{ job.workflow_sha || github.sha }} + path: .recognition-engine + sparse-checkout: | + utils + + - name: Collect PR metadata + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + echo "Collecting metadata for PR #${PR_NUMBER} in ${TARGET_REPO}..." + + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate > .pr-files.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate > .pr-commits.json + gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate > .existing-labels.json + + node -e ' + const fs = require("fs"); + const pr = JSON.parse(fs.readFileSync(".pr-info.json")); + const files = JSON.parse(fs.readFileSync(".pr-files.json")); + const commits = JSON.parse(fs.readFileSync(".pr-commits.json")); + fs.writeFileSync(".pr-metadata.json", JSON.stringify({ pr, files, commits }, null, 2)); + ' + + - name: Run badge award orchestrator + env: + TARGET_REPO: ${{ github.repository }} + run: | + set -euo pipefail + node .recognition-engine/utils/award-orchestrator.js \ + --metadata=".pr-metadata.json" \ + --existing-labels=".existing-labels.json" \ + --repo="${TARGET_REPO}" \ + --out=".evaluation-result.json" + + - name: Publish evaluation step summary + run: | + node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, res.summaryMarkdown + "\n"); + ' + + - name: Verify Slack credentials for production run + if: ${{ inputs.dry_run == false }} + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + run: | + PENDING_COUNT=$(node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + console.log(res.pendingAwards.length); + ') + + if [ "${PENDING_COUNT}" -gt 0 ] && [ -z "${SLACK_BOT_TOKEN:-}" ]; then + echo "::error::SLACK_BOT_TOKEN secret is required for production badge awards but was not provided. Failing workflow to prevent silent omission." + exit 1 + fi + + - name: Sequentially dispatch awards and apply tracking labels + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr_number }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + IS_DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + AWARDS_JSON=$(node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + console.log(JSON.stringify(res.pendingAwards)); + ') + + EMAIL=$(node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + console.log(res.recipientEmail || ""); + ') + + AWARD_COUNT=$(echo "${AWARDS_JSON}" | jq '. | length') + echo "Pending awards count: ${AWARD_COUNT}" + + if [ "${AWARD_COUNT}" -eq 0 ]; then + echo "No pending awards to dispatch." + exit 0 + fi + + for i in $(seq 0 $((AWARD_COUNT - 1))); do + BADGE_SLUG=$(echo "${AWARDS_JSON}" | jq -r ".[$i].slug") + BADGE_NAME=$(echo "${AWARDS_JSON}" | jq -r ".[$i].name") + LABEL_NAME=$(echo "${AWARDS_JSON}" | jq -r ".[$i].trackingLabel") + + echo "--------------------------------------------------------" + echo "Processing award $((i + 1)) of ${AWARD_COUNT}: ${BADGE_NAME} (${BADGE_SLUG})" + + # Step 1: Dispatch to Slack + if [ "${IS_DRY_RUN}" = "true" ]; then + echo "[DRY-RUN] Would post to Slack channel ${SLACK_CHANNEL}: /award-badge ${EMAIL} ${BADGE_SLUG}" + else + echo "Dispatching Slack command: /award-badge ${EMAIL} ${BADGE_SLUG}" + PAYLOAD=$(jq -n \ + --arg ch "${SLACK_CHANNEL}" \ + --arg txt "/award-badge ${EMAIL} ${BADGE_SLUG}" \ + '{channel: $ch, text: $txt}') + + SLACK_RESP=$(curl -s -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}") + + SLACK_OK=$(echo "${SLACK_RESP}" | jq -r '.ok // false') + if [ "${SLACK_OK}" != "true" ]; then + SLACK_ERR=$(echo "${SLACK_RESP}" | jq -r '.error // "unknown"') + echo "::error::Slack dispatch failed for badge ${BADGE_SLUG}: ${SLACK_ERR}" + exit 1 + fi + echo "Slack dispatch successful for ${BADGE_SLUG}." + fi + + # Step 2: Race-Safe Label Provisioning & Label Write + if [ "${IS_DRY_RUN}" = "true" ]; then + echo "[DRY-RUN] Would create/verify label '${LABEL_NAME}' and apply to PR #${PR_NUMBER}" + else + echo "Ensuring label '${LABEL_NAME}' exists on ${TARGET_REPO}..." + LABEL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${TARGET_REPO}/labels/${LABEL_NAME}") + + if [ "${LABEL_STATUS}" != "200" ]; then + CREATE_RESP_FILE=$(mktemp) + HTTP_CODE=$(curl -s -w "%{http_code}" -o "${CREATE_RESP_FILE}" \ + -X POST \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${TARGET_REPO}/labels" \ + -d "{\"name\":\"${LABEL_NAME}\",\"color\":\"0E7090\",\"description\":\"Automated contributor badge tracking\"}") + + if [ "${HTTP_CODE}" = "201" ]; then + echo "Label '${LABEL_NAME}' created successfully." + elif [ "${HTTP_CODE}" = "422" ]; then + IS_ALREADY_EXISTS=$(jq -r '.errors[]? | select(.code == "already_exists") | .code' "${CREATE_RESP_FILE}") + if [ "${IS_ALREADY_EXISTS}" = "already_exists" ]; then + echo "Label '${LABEL_NAME}' already exists (race condition resolved)." + else + echo "::error::Fatal 422 error creating label '${LABEL_NAME}': $(cat "${CREATE_RESP_FILE}")" + exit 1 + fi + else + echo "::error::Failed to create label '${LABEL_NAME}' (HTTP ${HTTP_CODE}): $(cat "${CREATE_RESP_FILE}")" + exit 1 + fi + fi + + # Apply label to PR + echo "Applying tracking label '${LABEL_NAME}' to PR #${PR_NUMBER}..." + gh issue edit "${PR_NUMBER}" --repo "${TARGET_REPO}" --add-label "${LABEL_NAME}" + echo "Applied tracking label '${LABEL_NAME}'." + fi + done + + echo "Badge award processing completed successfully." diff --git a/.github/workflows/test-badge-evaluator.yml b/.github/workflows/test-badge-evaluator.yml new file mode 100644 index 0000000..d4ab421 --- /dev/null +++ b/.github/workflows/test-badge-evaluator.yml @@ -0,0 +1,72 @@ +name: Test Badge Evaluator (Dry-Run) + +on: + workflow_dispatch: + inputs: + repository: + description: "Target repository to evaluate (e.g. layer5io/sistent, meshery/meshery)" + required: true + type: string + pr_number: + description: "Merged Pull Request number in the target repository" + required: true + type: number + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + test-evaluation: + name: Dry-Run Historical PR Evaluation + runs-on: ubuntu-latest + steps: + - name: Checkout recognition repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA + + - name: Collect target PR metadata + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPO: ${{ inputs.repository }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + echo "Evaluating PR #${PR_NUMBER} from external repository: ${TARGET_REPO}..." + + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate > .pr-files.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate > .pr-commits.json + gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate > .existing-labels.json + + node -e ' + const fs = require("fs"); + const pr = JSON.parse(fs.readFileSync(".pr-info.json")); + const files = JSON.parse(fs.readFileSync(".pr-files.json")); + const commits = JSON.parse(fs.readFileSync(".pr-commits.json")); + fs.writeFileSync(".pr-metadata.json", JSON.stringify({ pr, files, commits }, null, 2)); + ' + + - name: Run award orchestrator in dry-run mode + env: + TARGET_REPO: ${{ inputs.repository }} + run: | + set -euo pipefail + node utils/award-orchestrator.js \ + --metadata=".pr-metadata.json" \ + --existing-labels=".existing-labels.json" \ + --repo="${TARGET_REPO}" \ + --out=".evaluation-result.json" + + - name: Publish step summary + run: | + node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, res.summaryMarkdown + "\n"); + ' + + - name: Print dry-run evaluation report + run: | + echo "=== DRY RUN EVALUATION OUTPUT ===" + cat .evaluation-result.json | jq . diff --git a/package.json b/package.json index aad625e..288c094 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "start": "gatsby develop", "build": "gatsby build", "serve": "gatsby serve", - "clean": "gatsby clean" + "clean": "gatsby clean", + "test:badge-engine": "node --test utils/*.test.js" }, "proxy": "https://discuss.layer5.io/", "devDependencies": { diff --git a/utils/award-orchestrator.js b/utils/award-orchestrator.js new file mode 100644 index 0000000..57fec5f --- /dev/null +++ b/utils/award-orchestrator.js @@ -0,0 +1,244 @@ +const fs = require('fs'); +const path = require('path'); +const { evaluateBadges, normalizeLabels, normalizeFiles } = require('./badge-evaluator'); +const { resolveIdentity, maskEmail } = require('./identity-resolver'); + +/** + * Parses command line arguments formatted as --key=value or --key value + * @param {string[]} args + * @returns {Record} + */ +function parseArgs(args) { + const parsed = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--')) { + const equalsIdx = arg.indexOf('='); + if (equalsIdx !== -1) { + const key = arg.slice(2, equalsIdx); + const value = arg.slice(equalsIdx + 1); + parsed[key] = value; + } else { + const key = arg.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith('--')) { + parsed[key] = next; + i++; + } else { + parsed[key] = 'true'; + } + } + } + } + return parsed; +} + +/** + * Builds GitHub Actions step summary markdown + */ +function buildSummaryMarkdown({ + repo, + prAuthor, + resolvedEmail, + maskedEmail, + dcoVerified, + dcoReason, + allEligibleBadges, + alreadyAwardedBadges, + pendingAwards +}) { + const lines = []; + lines.push(`## 🎖️ Contributor Badge Evaluation Summary`); + lines.push(''); + lines.push(`- **Target Repository**: \`${repo}\``); + lines.push(`- **PR Author**: \`@${prAuthor || 'unknown'}\``); + + if (resolvedEmail) { + lines.push(`- **Recipient Identity**: \`${maskedEmail}\` (${dcoVerified ? '✅ DCO Verified' : '⚠️ DCO Unverified'})`); + } else { + lines.push(`- **Recipient Identity**: ⚠️ Unresolved email`); + } + lines.push(`- **Attribution Note**: ${dcoReason}`); + lines.push(''); + + if (allEligibleBadges.length === 0) { + lines.push(`> [!NOTE]`); + lines.push(`> No qualifying badge criteria matched for this pull request.`); + lines.push(''); + return lines.join('\n'); + } + + lines.push(`| Badge | Slug | Status | Tracking Label | Qualification Reason |`); + lines.push(`| :--- | :--- | :--- | :--- | :--- |`); + + for (const badge of allEligibleBadges) { + const isAlreadyAwarded = alreadyAwardedBadges.some(b => b.slug === badge.slug); + const trackingLabel = `\`badge-awarded:${badge.slug}\``; + + let status = '🚀 **Pending Dispatch**'; + if (isAlreadyAwarded) { + status = '✅ **Already Awarded**'; + } else if (!dcoVerified) { + status = '⚠️ **DCO Blocked**'; + } + + lines.push(`| **${badge.name}** | \`${badge.slug}\` | ${status} | ${trackingLabel} | ${badge.reason} |`); + } + + lines.push(''); + + if (pendingAwards.length > 0) { + lines.push(`### Planned Dispatches (${pendingAwards.length})`); + lines.push(''); + for (const award of pendingAwards) { + lines.push(`- **${award.name}** (\`${award.slug}\`) $\\rightarrow$ Tracking Label: \`${award.trackingLabel}\``); + } + lines.push(''); + } else if (alreadyAwardedBadges.length > 0 && allEligibleBadges.length === alreadyAwardedBadges.length) { + lines.push(`> [!NOTE]`); + lines.push(`> All eligible badges for this PR have already been awarded and labeled. Zero duplicate dispatches needed.`); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Orchestrates badge evaluation and award filtering. + * + * @param {Object} options + * @param {Object} options.prMetadata PR metadata object or file content + * @param {Array} [options.existingLabels] Existing labels on PR + * @param {string} [options.repoOverride] Explicit repository override + * @returns {Object} Structured evaluation result + */ +function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride = '' }) { + // Extract repository + const repo = ( + repoOverride || + prMetadata.repository || + prMetadata.repo || + (prMetadata.pr && prMetadata.pr.base && prMetadata.pr.base.repo && prMetadata.pr.base.repo.full_name) || + '' + ).trim(); + + // Extract author + const prAuthor = ( + prMetadata.prAuthor || + (prMetadata.pr && prMetadata.pr.user && prMetadata.pr.user.login) || + '' + ).trim(); + + // Extract labels on PR + const rawPrLabels = prMetadata.labels || (prMetadata.pr && prMetadata.pr.labels) || []; + const normalizedPrLabels = normalizeLabels(rawPrLabels); + + // Extract files + const rawFiles = prMetadata.changedFiles || prMetadata.files || []; + const normalizedFiles = normalizeFiles(rawFiles); + + // Extract commits + const commits = prMetadata.commits || []; + + // Evaluate badge eligibility + const { eligibleBadges } = evaluateBadges({ + repository: repo, + labels: normalizedPrLabels, + changedFiles: normalizedFiles + }); + + // Resolve identity and DCO + const identity = resolveIdentity(prAuthor, commits); + const maskedRecipientEmail = maskEmail(identity.resolvedEmail); + + // Extract existing tracking labels + const allExistingLabels = normalizeLabels(existingLabels.length > 0 ? existingLabels : rawPrLabels); + const existingTrackingPrefix = 'badge-awarded:'; + const alreadyAwardedSlugs = new Set( + allExistingLabels + .filter(lbl => lbl.startsWith(existingTrackingPrefix)) + .map(lbl => lbl.slice(existingTrackingPrefix.length)) + ); + + const alreadyAwardedBadges = eligibleBadges.filter(b => alreadyAwardedSlugs.has(b.slug)); + const unawardedBadges = eligibleBadges.filter(b => !alreadyAwardedSlugs.has(b.slug)); + + // Only dispatch if DCO is verified and email was resolved + const pendingAwards = []; + if (identity.dcoVerified && identity.resolvedEmail) { + for (const badge of unawardedBadges) { + pendingAwards.push({ + slug: badge.slug, + name: badge.name, + ruleId: badge.ruleId, + reason: badge.reason, + trackingLabel: `badge-awarded:${badge.slug}`, + slackCommand: `/award-badge ${identity.resolvedEmail} ${badge.slug}` + }); + } + } + + const summaryMarkdown = buildSummaryMarkdown({ + repo, + prAuthor, + resolvedEmail: identity.resolvedEmail, + maskedEmail: maskedRecipientEmail, + dcoVerified: identity.dcoVerified, + dcoReason: identity.reason, + allEligibleBadges: eligibleBadges, + alreadyAwardedBadges, + pendingAwards + }); + + return { + repo, + prAuthor, + recipientEmail: identity.resolvedEmail, + maskedEmail: maskedRecipientEmail, + dcoVerified: identity.dcoVerified, + dcoReason: identity.reason, + allEligibleBadges: eligibleBadges, + alreadyAwardedBadges, + unawardedBadges, + pendingAwards, + summaryMarkdown + }; +} + +/** + * CLI execution entrypoint + */ +function runCli() { + const args = parseArgs(process.argv.slice(2)); + + let prMetadata = {}; + if (args.metadata) { + const raw = fs.readFileSync(path.resolve(args.metadata), 'utf-8'); + prMetadata = JSON.parse(raw); + } + + let existingLabels = []; + if (args['existing-labels']) { + const raw = fs.readFileSync(path.resolve(args['existing-labels']), 'utf-8'); + existingLabels = JSON.parse(raw); + } + + const repoOverride = args.repo || ''; + const result = orchestrateAwards({ prMetadata, existingLabels, repoOverride }); + + if (args.out) { + fs.writeFileSync(path.resolve(args.out), JSON.stringify(result, null, 2), 'utf-8'); + } else { + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } +} + +if (require.main === module) { + runCli(); +} + +module.exports = { + orchestrateAwards, + parseArgs, + buildSummaryMarkdown +}; diff --git a/utils/award-orchestrator.test.js b/utils/award-orchestrator.test.js new file mode 100644 index 0000000..924b3a3 --- /dev/null +++ b/utils/award-orchestrator.test.js @@ -0,0 +1,137 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { orchestrateAwards, parseArgs } = require('./award-orchestrator'); + +test('parseArgs parses flags and key-values', () => { + const args = ['--metadata=foo.json', '--repo', 'layer5io/sistent', '--dry-run']; + const parsed = parseArgs(args); + assert.equal(parsed.metadata, 'foo.json'); + assert.equal(parsed.repo, 'layer5io/sistent'); + assert.equal(parsed['dry-run'], 'true'); +}); + +test('orchestrateAwards produces pending award on qualifying fresh PR', () => { + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + changedFiles: ['src/components/Button/index.tsx'], + labels: ['enhancement'], + commits: [ + { + author: { login: 'contributor1' }, + commit: { + message: 'feat: add button component\n\nSigned-off-by: Contributor One ' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + + assert.equal(result.dcoVerified, true); + assert.equal(result.recipientEmail, 'contrib@layer5.io'); + assert.equal(result.allEligibleBadges.length, 1); + assert.equal(result.allEligibleBadges[0].slug, 'sistent-contributor'); + assert.equal(result.pendingAwards.length, 1); + assert.equal(result.pendingAwards[0].slug, 'sistent-contributor'); + assert.equal(result.pendingAwards[0].trackingLabel, 'badge-awarded:sistent-contributor'); + assert.equal(result.pendingAwards[0].slackCommand, '/award-badge contrib@layer5.io sistent-contributor'); + assert.equal(result.alreadyAwardedBadges.length, 0); + assert.ok(result.summaryMarkdown.includes('Pending Dispatch')); +}); + +test('orchestrateAwards filters out already awarded badges (Idempotency)', () => { + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + changedFiles: ['src/components/Button/index.tsx'], + labels: ['enhancement'], + commits: [ + { + author: { login: 'contributor1' }, + commit: { + message: 'feat: add button\n\nSigned-off-by: Contributor One ' + } + } + ] + }; + + const existingLabels = ['enhancement', 'badge-awarded:sistent-contributor']; + const result = orchestrateAwards({ prMetadata, existingLabels }); + + assert.equal(result.allEligibleBadges.length, 1); + assert.equal(result.alreadyAwardedBadges.length, 1); + assert.equal(result.alreadyAwardedBadges[0].slug, 'sistent-contributor'); + assert.equal(result.pendingAwards.length, 0, 'Must have zero pending awards when already labeled'); + assert.ok(result.summaryMarkdown.includes('Already Awarded')); + assert.ok(result.summaryMarkdown.includes('Zero duplicate dispatches needed')); +}); + +test('orchestrateAwards blocks awards when DCO is unverified', () => { + const prMetadata = { + repository: 'meshery/meshery', + prAuthor: 'author2', + changedFiles: ['server/main.go'], + labels: [], + commits: [ + { + author: { login: 'author2' }, + commit: { + message: 'fix: update server initialization without dco' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + + assert.equal(result.dcoVerified, false); + assert.equal(result.allEligibleBadges.length, 1); + assert.equal(result.pendingAwards.length, 0, 'Cannot award badge without verified DCO'); + assert.ok(result.summaryMarkdown.includes('DCO Blocked')); +}); + +test('orchestrateAwards CLI file integration works via temp files', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'award-test-')); + const metaFile = path.join(tmpDir, 'pr-meta.json'); + const labelsFile = path.join(tmpDir, 'labels.json'); + const outFile = path.join(tmpDir, 'out.json'); + + const prMetadata = { + repository: 'meshery/meshsync', + prAuthor: 'dev3', + changedFiles: ['internal/sync.go'], + commits: [ + { + author: { login: 'dev3' }, + commit: { + message: 'feat: sync\n\nSigned-off-by: Dev Three ' + } + } + ] + }; + + fs.writeFileSync(metaFile, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsFile, JSON.stringify(['area/sync']), 'utf-8'); + + // Programmatic CLI run + const { execFileSync } = require('child_process'); + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metaFile}`, + `--existing-labels=${labelsFile}`, + `--out=${outFile}` + ]); + + assert.ok(fs.existsSync(outFile)); + const output = JSON.parse(fs.readFileSync(outFile, 'utf-8')); + assert.equal(output.pendingAwards.length, 1); + assert.equal(output.pendingAwards[0].slug, 'meshsync'); + + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); diff --git a/utils/badge-evaluator.js b/utils/badge-evaluator.js new file mode 100644 index 0000000..de421e7 --- /dev/null +++ b/utils/badge-evaluator.js @@ -0,0 +1,171 @@ +const defaultRules = require('./badge-rules.json'); + +/** + * Matches a glob pattern against a normalized relative file path. + * Supports: + * - `**` : arbitrary directories / subdirectories + * - `*` : wildcards within path segment / filename + * - exact file or path matches + * + * @param {string} pattern Glob pattern (e.g. "src/**", "**\/*.test.*") + * @param {string} filePath Normalized file path (e.g. "src/components/button.tsx") + * @returns {boolean} + */ +function matchGlob(pattern, filePath) { + if (!pattern || !filePath) return false; + + const normPath = filePath.replace(/\\/g, '/').replace(/^\/+/, ''); + const normPattern = pattern.replace(/\\/g, '/').replace(/^\/+/, ''); + + if (normPattern === normPath) return true; + + let regexStr = '^'; + let i = 0; + while (i < normPattern.length) { + const c = normPattern[i]; + if (c === '*' && normPattern[i + 1] === '*') { + if (normPattern[i + 2] === '/') { + regexStr += '(?:.*/)?'; + i += 3; + } else { + regexStr += '.*'; + i += 2; + } + } else if (c === '*') { + regexStr += '[^/]*'; + i += 1; + } else if (['.', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']'].includes(c)) { + regexStr += '\\' + c; + i += 1; + } else { + regexStr += c; + i += 1; + } + } + regexStr += '$'; + + try { + return new RegExp(regexStr).test(normPath); + } catch { + return false; + } +} + +/** + * Normalizes label inputs to lowercase string array + * @param {Array} labels + * @returns {string[]} + */ +function normalizeLabels(labels) { + if (!Array.isArray(labels)) return []; + return labels + .map(label => { + if (typeof label === 'string') return label.trim().toLowerCase(); + if (label && typeof label.name === 'string') return label.name.trim().toLowerCase(); + return ''; + }) + .filter(Boolean); +} + +/** + * Normalizes file paths + * @param {Array} files + * @returns {string[]} + */ +function normalizeFiles(files) { + if (!Array.isArray(files)) return []; + return files + .map(file => { + if (typeof file === 'string') return file.trim().replace(/\\/g, '/'); + if (file && typeof file.filename === 'string') return file.filename.trim().replace(/\\/g, '/'); + return ''; + }) + .filter(Boolean); +} + +/** + * Evaluates a pull request's metadata against badge rules. + * Pure function: (repo, labels, changedFiles, rules) -> { eligibleBadges: [ { slug, name, reason, ruleId } ] } + * Zero Git or network dependencies. + * + * @param {Object} prContext + * @param {string} prContext.repository Full repo name (e.g. "layer5io/sistent") + * @param {Array} [prContext.labels] PR labels + * @param {Array} [prContext.changedFiles] List of changed files + * @param {Array} [rules] Optional badge rules override + * @returns {{ eligibleBadges: Array<{ slug: string, name: string, reason: string, ruleId: string }> }} + */ +function evaluateBadges(prContext = {}, rules = defaultRules) { + const repository = (prContext.repository || prContext.repo || '').trim().toLowerCase(); + const rawLabels = normalizeLabels(prContext.labels); + const rawFiles = normalizeFiles(prContext.changedFiles || prContext.files); + + if (!repository) { + return { eligibleBadges: [] }; + } + + const eligibleBadges = []; + + for (const rule of rules) { + const supportedRepos = (rule.repositories || []).map(r => r.toLowerCase()); + if (!supportedRepos.includes(repository)) { + continue; + } + + // Check label requirements (if rule specifies requiredAnyLabels) + if (rule.requiredAnyLabels && rule.requiredAnyLabels.length > 0) { + const requiredAny = rule.requiredAnyLabels.map(l => l.toLowerCase()); + const hasMatchingLabel = rawLabels.some(label => requiredAny.includes(label)); + if (!hasMatchingLabel) { + continue; + } + } + + // Determine applicable include patterns + let includePatterns = rule.includePatterns || []; + if (rule.repoSpecificIncludePatterns) { + for (const [repoKey, patterns] of Object.entries(rule.repoSpecificIncludePatterns)) { + if (repoKey.toLowerCase() === repository) { + includePatterns = includePatterns.concat(patterns); + } + } + } + + const excludePatterns = rule.excludePatterns || []; + + // Filter changed files: must match at least one include pattern, and NOT match any exclude pattern + const matchingFiles = rawFiles.filter(filePath => { + const isIncluded = includePatterns.some(pat => matchGlob(pat, filePath)); + if (!isIncluded) return false; + const isExcluded = excludePatterns.some(pat => matchGlob(pat, filePath)); + return !isExcluded; + }); + + if (matchingFiles.length > 0) { + const sampleFiles = matchingFiles.slice(0, 3).join(', '); + const moreSuffix = matchingFiles.length > 3 ? ` and ${matchingFiles.length - 3} more` : ''; + let reason = `Modified ${matchingFiles.length} file(s) matching criteria (${sampleFiles}${moreSuffix})`; + + if (rule.requiredAnyLabels && rule.requiredAnyLabels.length > 0) { + const matchedLabel = rawLabels.find(l => rule.requiredAnyLabels.map(r => r.toLowerCase()).includes(l)); + reason = `PR labeled '${matchedLabel}' and modified ${matchingFiles.length} file(s) (${sampleFiles}${moreSuffix})`; + } + + eligibleBadges.push({ + slug: rule.slug, + name: rule.name, + reason, + ruleId: rule.ruleId + }); + } + } + + return { eligibleBadges }; +} + +module.exports = { + evaluateBadges, + matchGlob, + normalizeLabels, + normalizeFiles +}; diff --git a/utils/badge-evaluator.test.js b/utils/badge-evaluator.test.js new file mode 100644 index 0000000..82aef72 --- /dev/null +++ b/utils/badge-evaluator.test.js @@ -0,0 +1,196 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { evaluateBadges, matchGlob } = require('./badge-evaluator'); + +test('matchGlob utility handles patterns accurately', () => { + // Exact matches + assert.equal(matchGlob('main.go', 'main.go'), true); + assert.equal(matchGlob('main.go', 'server/main.go'), false); + + // Single asterisk within path segment + assert.equal(matchGlob('src/*.ts', 'src/index.ts'), true); + assert.equal(matchGlob('src/*.ts', 'src/sub/index.ts'), false); + + // Double asterisk directory wildcard + assert.equal(matchGlob('src/**', 'src/components/button.tsx'), true); + assert.equal(matchGlob('src/**', 'packages/theme/index.ts'), false); + + // Test and spec exclusion globs + assert.equal(matchGlob('**/*.test.*', 'ui/components/button.test.tsx'), true); + assert.equal(matchGlob('**/*.test.*', 'ui/components/button.tsx'), false); + assert.equal(matchGlob('**/__tests__/**', 'src/__tests__/app.test.js'), true); +}); + +test('sistent-contributor badge evaluation', () => { + // Qualifying files + const qualifying = evaluateBadges({ + repository: 'layer5io/sistent', + changedFiles: ['src/components/button.tsx', 'package.json'] + }); + assert.equal(qualifying.eligibleBadges.length, 1); + assert.equal(qualifying.eligibleBadges[0].slug, 'sistent-contributor'); + + // Disqualifying root metadata / non-code + const disqualified = evaluateBadges({ + repository: 'layer5io/sistent', + changedFiles: ['.github/workflows/ci.yml', '.gitignore', 'LICENSE', 'CODE_OF_CONDUCT.md'] + }); + assert.equal(disqualified.eligibleBadges.length, 0); + + // Case insensitive repository check + const caseInsensitive = evaluateBadges({ + repository: 'Layer5IO/Sistent', + changedFiles: ['packages/theme/index.js'] + }); + assert.equal(caseInsensitive.eligibleBadges.length, 1); + assert.equal(caseInsensitive.eligibleBadges[0].slug, 'sistent-contributor'); +}); + +test('meshery core vs meshery-docs evaluation in meshery/meshery', () => { + // Core functional code modification + const coreResult = evaluateBadges({ + repository: 'meshery/meshery', + changedFiles: ['server/handlers/patterns.go', 'mesheryctl/cmd/system.go'] + }); + const coreSlugs = coreResult.eligibleBadges.map(b => b.slug); + assert.ok(coreSlugs.includes('meshery')); + assert.ok(!coreSlugs.includes('meshery-docs')); + + // Documentation-only modification + const docsResult = evaluateBadges({ + repository: 'meshery/meshery', + changedFiles: ['docs/concepts/architecture.md', 'docs/install/index.md'] + }); + const docsSlugs = docsResult.eligibleBadges.map(b => b.slug); + assert.ok(!docsSlugs.includes('meshery'), 'Docs-only PR must not earn meshery core badge'); + assert.ok(docsSlugs.includes('meshery-docs'), 'Must earn meshery-docs badge'); + + // Root markdown & CI exclusions + const metaResult = evaluateBadges({ + repository: 'meshery/meshery', + changedFiles: ['README.md', 'ROADMAP.md', '.github/workflows/test.yml'] + }); + assert.equal(metaResult.eligibleBadges.length, 0); + + // Mixed PR modifying both server and docs + const mixedResult = evaluateBadges({ + repository: 'meshery/meshery', + changedFiles: ['server/main.go', 'docs/quickstart.md'] + }); + const mixedSlugs = mixedResult.eligibleBadges.map(b => b.slug); + assert.ok(mixedSlugs.includes('meshery')); + assert.ok(mixedSlugs.includes('meshery-docs')); +}); + +test('meshery-operator and meshsync badge evaluation', () => { + // Operator controller modification + const opResult = evaluateBadges({ + repository: 'meshery/meshery-operator', + changedFiles: ['controllers/meshery_controller.go', 'api/v1alpha1/types.go'] + }); + assert.equal(opResult.eligibleBadges.length, 1); + assert.equal(opResult.eligibleBadges[0].slug, 'meshery-operator'); + + // MeshSync internal logic modification + const syncResult = evaluateBadges({ + repository: 'meshery/meshsync', + changedFiles: ['internal/daemon/sync.go', 'pkg/broker/client.go'] + }); + assert.equal(syncResult.eligibleBadges.length, 1); + assert.equal(syncResult.eligibleBadges[0].slug, 'meshsync'); + + // Operator non-code metadata excluded + const opMeta = evaluateBadges({ + repository: 'meshery/meshery-operator', + changedFiles: ['README.md', 'LICENSE', '.github/workflows/ci.yml'] + }); + assert.equal(opMeta.eligibleBadges.length, 0); +}); + +test('meshery-docs in layer5io/docs', () => { + const docsResult = evaluateBadges({ + repository: 'layer5io/docs', + changedFiles: ['content/overview/index.md', 'pages/getting-started.tsx'] + }); + assert.equal(docsResult.eligibleBadges.length, 1); + assert.equal(docsResult.eligibleBadges[0].slug, 'meshery-docs'); +}); + +test('meshery-catalog evaluation in meshery.io and meshery', () => { + // In meshery/meshery.io + const catalogWeb = evaluateBadges({ + repository: 'meshery/meshery.io', + changedFiles: ['collections/catalog/wasm-filter.json', 'catalog/kubernetes/item.yaml'] + }); + assert.equal(catalogWeb.eligibleBadges.length, 1); + assert.equal(catalogWeb.eligibleBadges[0].slug, 'meshery-catalog'); + + // In meshery/meshery models + const catalogModels = evaluateBadges({ + repository: 'meshery/meshery', + changedFiles: ['models/patterns/design.json'] + }); + const slugs = catalogModels.eligibleBadges.map(b => b.slug); + assert.ok(slugs.includes('meshery-catalog')); +}); + +test('landscape badge evaluation in layer5io/layer5', () => { + // Modifying landscape data + const landscapeResult = evaluateBadges({ + repository: 'layer5io/layer5', + changedFiles: ['src/collections/landscape/service-mesh.json'] + }); + assert.equal(landscapeResult.eligibleBadges.length, 1); + assert.equal(landscapeResult.eligibleBadges[0].slug, 'landscape'); + + // Modifying blog / news collections must be excluded + const blogResult = evaluateBadges({ + repository: 'layer5io/layer5', + changedFiles: ['src/collections/blog/announcement.md', 'src/collections/news/update.md'] + }); + assert.equal(blogResult.eligibleBadges.length, 0); +}); + +test('ui-ux badge evaluation with labels and frontend files', () => { + // Qualifying: area/ui label + meshery UI component + const mesheryUi = evaluateBadges({ + repository: 'meshery/meshery', + labels: ['area/ui', 'enhancement'], + changedFiles: ['ui/components/Navigator.tsx'] + }); + assert.ok(mesheryUi.eligibleBadges.some(b => b.slug === 'ui-ux')); + + // Qualifying: area/ux label + sistent component + const sistentUi = evaluateBadges({ + repository: 'layer5io/sistent', + labels: [{ name: 'area/ux' }], + changedFiles: ['src/components/Modal/index.tsx'] + }); + const sistentSlugs = sistentUi.eligibleBadges.map(b => b.slug); + assert.ok(sistentSlugs.includes('ui-ux')); + assert.ok(sistentSlugs.includes('sistent-contributor')); + + // Qualifying: area/ui + layer5 section + const layer5Ui = evaluateBadges({ + repository: 'layer5io/layer5', + labels: ['area/ui'], + changedFiles: ['src/sections/Home/Banner.tsx'] + }); + assert.ok(layer5Ui.eligibleBadges.some(b => b.slug === 'ui-ux')); + + // Missing required label even if frontend file modified + const noLabel = evaluateBadges({ + repository: 'meshery/meshery', + labels: ['bug'], + changedFiles: ['ui/components/Navigator.tsx'] + }); + assert.ok(!noLabel.eligibleBadges.some(b => b.slug === 'ui-ux')); + + // Only test files modified with area/ui label + const testFilesOnly = evaluateBadges({ + repository: 'meshery/meshery', + labels: ['area/ui'], + changedFiles: ['ui/components/__tests__/Navigator.test.tsx', 'package-lock.json'] + }); + assert.ok(!testFilesOnly.eligibleBadges.some(b => b.slug === 'ui-ux')); +}); diff --git a/utils/badge-rules.json b/utils/badge-rules.json new file mode 100644 index 0000000..ca90c5b --- /dev/null +++ b/utils/badge-rules.json @@ -0,0 +1,177 @@ +[ + { + "slug": "sistent-contributor", + "name": "Sistent Contributor", + "ruleId": "rule-sistent-contributor", + "description": "Consistent & impactful contributions to Sistent", + "repositories": ["layer5io/sistent"], + "includePatterns": [ + "src/**", + "packages/**", + "system/**" + ], + "excludePatterns": [ + ".github/**", + ".gitignore", + "LICENSE", + "CODE_OF_CONDUCT.md" + ] + }, + { + "slug": "meshery", + "name": "Meshery", + "ruleId": "rule-meshery-core", + "description": "Consistent & impactful contributions to Meshery core", + "repositories": ["meshery/meshery"], + "includePatterns": [ + "server/**", + "mesheryctl/**", + "models/**", + "install/**", + "main.go" + ], + "excludePatterns": [ + "docs/**", + "README.md", + "ROADMAP.md", + ".github/**" + ] + }, + { + "slug": "meshery-operator", + "name": "Meshery Operator", + "ruleId": "rule-meshery-operator", + "description": "Contributions to Meshery Operator", + "repositories": ["meshery/meshery-operator"], + "includePatterns": [ + "controllers/**", + "api/**", + "pkg/**", + "main.go" + ], + "excludePatterns": [ + ".github/**", + "LICENSE", + "README.md" + ] + }, + { + "slug": "meshsync", + "name": "MeshSync", + "ruleId": "rule-meshsync", + "description": "Contributions to MeshSync", + "repositories": ["meshery/meshsync"], + "includePatterns": [ + "internal/**", + "pkg/**", + "main.go" + ], + "excludePatterns": [ + ".github/**", + "LICENSE", + "README.md" + ] + }, + { + "slug": "meshery-docs", + "name": "Meshery Docs", + "ruleId": "rule-meshery-docs", + "description": "Contributions to documentation", + "repositories": [ + "meshery/meshery", + "layer5io/docs" + ], + "repoSpecificIncludePatterns": { + "meshery/meshery": [ + "docs/**" + ], + "layer5io/docs": [ + "content/**", + "pages/**" + ] + }, + "excludePatterns": [ + ".github/**", + "LICENSE", + "README.md" + ] + }, + { + "slug": "meshery-catalog", + "name": "Meshery Catalog", + "ruleId": "rule-meshery-catalog", + "description": "Contributions to Meshery Catalog", + "repositories": [ + "meshery/meshery.io", + "meshery/meshery" + ], + "repoSpecificIncludePatterns": { + "meshery/meshery.io": [ + "catalog/**", + "collections/catalog/**" + ], + "meshery/meshery": [ + "models/**" + ] + }, + "excludePatterns": [ + ".github/**", + "LICENSE", + "README.md" + ] + }, + { + "slug": "landscape", + "name": "Landscape", + "ruleId": "rule-landscape", + "description": "Contributions to Layer5 Landscape", + "repositories": ["layer5io/layer5"], + "includePatterns": [ + "src/collections/landscape/**" + ], + "excludePatterns": [ + "src/collections/blog/**", + "src/collections/news/**", + "src/collections/members/**", + ".github/**", + "LICENSE", + "README.md" + ] + }, + { + "slug": "ui-ux", + "name": "UI/UX", + "ruleId": "rule-ui-ux", + "description": "Creating/improving visual designs or user flows", + "repositories": [ + "meshery/meshery", + "layer5io/sistent", + "layer5io/layer5" + ], + "requiredAnyLabels": [ + "area/ui", + "area/ux" + ], + "repoSpecificIncludePatterns": { + "meshery/meshery": [ + "ui/**", + "provider-ui/**" + ], + "layer5io/sistent": [ + "src/**", + "system/**" + ], + "layer5io/layer5": [ + "src/components/**", + "src/sections/**" + ] + }, + "excludePatterns": [ + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*", + "package-lock.json", + "yarn.lock" + ] + } +] diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js new file mode 100644 index 0000000..a86b3c2 --- /dev/null +++ b/utils/identity-resolver.js @@ -0,0 +1,135 @@ +/** + * Masks an email address for safe public reporting in step summaries and logs. + * Example: "john.doe@example.com" -> "j***e@example.com" + * + * @param {string} email + * @returns {string} + */ +function maskEmail(email) { + if (!email || typeof email !== 'string') return ''; + const trimmed = email.trim(); + const atIndex = trimmed.lastIndexOf('@'); + if (atIndex <= 0) return '***'; + + const user = trimmed.slice(0, atIndex); + const domain = trimmed.slice(atIndex + 1); + + if (user.length <= 2) { + return `${user[0]}***@${domain}`; + } + return `${user[0]}***${user[user.length - 1]}@${domain}`; +} + +/** + * Extracts all Signed-off-by trailers from a commit message. + * Formats supported: "Signed-off-by: First Last " + * + * @param {string} message Commit message + * @returns {Array<{ name: string, email: string }>} + */ +function extractDcoTrailers(message) { + if (!message || typeof message !== 'string') return []; + const trailers = []; + const regex = /Signed-off-by:\s*([^<\r\n]+)<([^>\r\n]+)>/gi; + let match; + while ((match = regex.exec(message)) !== null) { + const name = match[1].trim(); + const email = match[2].trim().toLowerCase(); + if (email && email.includes('@')) { + trailers.push({ name, email }); + } + } + return trailers; +} + +/** + * Resolves contributor identity and verifies DCO compliance against commit history. + * Pure function: (authorLogin, commits) -> { resolvedEmail, dcoVerified, reason } + * Zero Git or network dependencies. + * + * @param {string} authorLogin PR author's GitHub login handle + * @param {Array} commits List of commit objects (from GitHub API pulls/commits) + * @returns {{ resolvedEmail: string|null, dcoVerified: boolean, reason: string }} + */ +function resolveIdentity(authorLogin, commits) { + if (!authorLogin || typeof authorLogin !== 'string') { + return { + resolvedEmail: null, + dcoVerified: false, + reason: 'Missing or invalid PR author login' + }; + } + + if (!Array.isArray(commits) || commits.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: 'No commits provided for evaluation' + }; + } + + const normalizedAuthor = authorLogin.trim().toLowerCase(); + + // Find commits where GitHub author matches the PR author + const authorCommits = commits.filter(item => { + if (!item) return false; + const commitAuthorLogin = item.author && item.author.login ? item.author.login.trim().toLowerCase() : null; + // Fallback: check committer if author is missing + const commitCommitterLogin = item.committer && item.committer.login ? item.committer.login.trim().toLowerCase() : null; + return commitAuthorLogin === normalizedAuthor || (!commitAuthorLogin && commitCommitterLogin === normalizedAuthor); + }); + + if (authorCommits.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `No commits in PR matched GitHub login '${authorLogin}'` + }; + } + + const signedEmails = []; + const missingDcoShas = []; + + for (const item of authorCommits) { + const message = item.commit ? item.commit.message : (item.message || ''); + const sha = (item.sha || 'unknown').slice(0, 7); + const trailers = extractDcoTrailers(message); + + if (trailers.length === 0) { + missingDcoShas.push(sha); + } else { + // Collect valid email + signedEmails.push(trailers[0].email); + } + } + + if (missingDcoShas.length > 0) { + const firstResolvedEmail = signedEmails.length > 0 ? signedEmails[0] : null; + return { + resolvedEmail: firstResolvedEmail, + dcoVerified: false, + reason: `DCO Signed-off-by trailer missing in ${missingDcoShas.length} commit(s) by ${authorLogin} (e.g. ${missingDcoShas.slice(0, 3).join(', ')})` + }; + } + + // Count email occurrences to find primary address + const emailCounts = {}; + for (const email of signedEmails) { + emailCounts[email] = (emailCounts[email] || 0) + 1; + } + + const sortedEmails = Object.keys(emailCounts).sort((a, b) => emailCounts[b] - emailCounts[a]); + const primaryEmail = sortedEmails[0] || null; + + return { + resolvedEmail: primaryEmail, + dcoVerified: true, + reason: `Verified ${authorCommits.length} commit(s) by ${authorLogin} with valid Signed-off-by trailer` + }; +} + +module.exports = { + resolveIdentity, + extractDcoTrailers, + maskEmail +}; diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js new file mode 100644 index 0000000..42d5676 --- /dev/null +++ b/utils/identity-resolver.test.js @@ -0,0 +1,98 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveIdentity, extractDcoTrailers, maskEmail } = require('./identity-resolver'); + +test('maskEmail obfuscates email addresses correctly', () => { + assert.equal(maskEmail('john.doe@example.com'), 'j***e@example.com'); + assert.equal(maskEmail('a@layer5.io'), 'a***@layer5.io'); + assert.equal(maskEmail('lee@layer5.io'), 'l***e@layer5.io'); + assert.equal(maskEmail(''), ''); + assert.equal(maskEmail(null), ''); +}); + +test('extractDcoTrailers extracts standard trailers', () => { + const msg = `feat(core): add feature\n\nSigned-off-by: Lee Calcote `; + const trailers = extractDcoTrailers(msg); + assert.equal(trailers.length, 1); + assert.equal(trailers[0].name, 'Lee Calcote'); + assert.equal(trailers[0].email, 'lee@layer5.io'); +}); + +test('resolveIdentity succeeds on verified single commit', () => { + const commits = [ + { + sha: 'abcdef1234567890', + author: { login: 'leecalcote' }, + commit: { + author: { name: 'Lee Calcote', email: 'lee@layer5.io' }, + message: 'fix: update configuration\n\nSigned-off-by: Lee Calcote ' + } + } + ]; + + const result = resolveIdentity('leecalcote', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'lee@layer5.io'); +}); + +test('resolveIdentity succeeds with case-insensitive login comparison', () => { + const commits = [ + { + sha: 'abcdef1234567890', + author: { login: 'LeeCalcote' }, + commit: { + author: { name: 'Lee Calcote', email: 'lee@layer5.io' }, + message: 'docs: update readme\n\nSigned-off-by: Lee Calcote ' + } + } + ]; + + const result = resolveIdentity('leecalcote', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'lee@layer5.io'); +}); + +test('resolveIdentity fails DCO if Signed-off-by is absent in any author commit', () => { + const commits = [ + { + sha: '1111111111111111', + author: { login: 'contributor1' }, + commit: { + message: 'first commit\n\nSigned-off-by: Contributor ' + } + }, + { + sha: '2222222222222222', + author: { login: 'contributor1' }, + commit: { + message: 'second commit without DCO' + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, false); + assert.ok(result.reason.includes('DCO Signed-off-by trailer missing')); +}); + +test('resolveIdentity fails if no commits belong to the PR author', () => { + const commits = [ + { + sha: '3333333333333333', + author: { login: 'someoneelse' }, + commit: { + message: 'commit\n\nSigned-off-by: Someone ' + } + } + ]; + + const result = resolveIdentity('actualAuthor', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes("No commits in PR matched GitHub login 'actualAuthor'")); +}); + +test('resolveIdentity gracefully handles empty inputs', () => { + assert.equal(resolveIdentity('', []).dcoVerified, false); + assert.equal(resolveIdentity('user', []).dcoVerified, false); +}); From 59cb1e9a4f75f824707fd002c215d4cbe501f722 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 18 Sep 2026 19:47:22 +0000 Subject: [PATCH 02/12] fix(badges): harden pagination, email privacy, DCO attribution, and permissions - Ingest paginated GitHub API responses with --slurp and jq flattening - Eliminate plaintext email logging in stdout, logs, and Step Summary - Restrict DCO attribution to PR author without committer fallback - Validate RFC-compliant email formats for Signed-off-by trailers - Remove pull-requests: write permission and keep minimal permissions - Remove fail-open SHA fallback for trusted engine checkout - Enforce canonical Slack channel constant CLDRKJZ0T - Add integration and regression test coverage for full pipeline Signed-off-by: Parth Gartan --- .github/workflows/award-project-badge.yml | 44 ++--- .github/workflows/test-badge-evaluator.yml | 11 +- utils/award-orchestrator.js | 180 ++++++++++++++++++-- utils/award-orchestrator.test.js | 115 +++++++++++-- utils/badge-evaluator.test.js | 50 ++++-- utils/badge-rules.json | 64 +++++-- utils/identity-resolver.js | 183 +++++++++++++++------ utils/identity-resolver.test.js | 143 +++++++++++++--- utils/workflow-integration.test.js | 171 +++++++++++++++++++ 9 files changed, 805 insertions(+), 156 deletions(-) create mode 100644 utils/workflow-integration.test.js diff --git a/.github/workflows/award-project-badge.yml b/.github/workflows/award-project-badge.yml index ad60d0f..5c1c1c0 100644 --- a/.github/workflows/award-project-badge.yml +++ b/.github/workflows/award-project-badge.yml @@ -12,11 +12,6 @@ on: required: false type: boolean default: false - slack_channel: - description: "Slack channel ID for bot command dispatch" - required: false - type: string - default: "CLDRKJZ0T" secrets: SLACK_BOT_TOKEN: description: "Slack Bot Token for /award-badge dispatches" @@ -25,7 +20,6 @@ on: permissions: contents: read issues: write - pull-requests: write concurrency: group: badge-award-${{ github.repository }}-${{ inputs.pr_number }} @@ -39,8 +33,8 @@ jobs: - name: Checkout trusted recognition engine uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA with: - repository: ${{ job.workflow_repository || 'layer5io/recognition' }} - ref: ${{ job.workflow_sha || github.sha }} + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} path: .recognition-engine sparse-checkout: | utils @@ -55,9 +49,9 @@ jobs: echo "Collecting metadata for PR #${PR_NUMBER} in ${TARGET_REPO}..." gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json - gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate > .pr-files.json - gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate > .pr-commits.json - gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate > .existing-labels.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate --slurp | jq 'add // []' > .pr-files.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate --slurp | jq 'add // []' > .pr-commits.json + gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate --slurp | jq 'add // []' > .existing-labels.json node -e ' const fs = require("fs"); @@ -76,7 +70,8 @@ jobs: --metadata=".pr-metadata.json" \ --existing-labels=".existing-labels.json" \ --repo="${TARGET_REPO}" \ - --out=".evaluation-result.json" + --out=".evaluation-result.json" \ + --dispatch-out=".dispatch-context.json" - name: Publish evaluation step summary run: | @@ -108,23 +103,32 @@ jobs: TARGET_REPO: ${{ github.repository }} PR_NUMBER: ${{ inputs.pr_number }} SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - SLACK_CHANNEL: ${{ inputs.slack_channel }} + CANONICAL_SLACK_CHANNEL: "CLDRKJZ0T" IS_DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail AWARDS_JSON=$(node -e ' const fs = require("fs"); - const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); - console.log(JSON.stringify(res.pendingAwards)); + const res = JSON.parse(fs.readFileSync(".dispatch-context.json")); + console.log(JSON.stringify(res.pendingAwards || [])); ') EMAIL=$(node -e ' const fs = require("fs"); - const res = JSON.parse(fs.readFileSync(".evaluation-result.json")); + const res = JSON.parse(fs.readFileSync(".dispatch-context.json")); console.log(res.recipientEmail || ""); ') + MASKED_EMAIL=$(node -e ' + const fs = require("fs"); + const res = JSON.parse(fs.readFileSync(".dispatch-context.json")); + console.log(res.maskedEmail || "unknown"); + ') + + # Securely wipe dispatch context file containing raw recipient email + rm -f .dispatch-context.json + AWARD_COUNT=$(echo "${AWARDS_JSON}" | jq '. | length') echo "Pending awards count: ${AWARD_COUNT}" @@ -141,13 +145,13 @@ jobs: echo "--------------------------------------------------------" echo "Processing award $((i + 1)) of ${AWARD_COUNT}: ${BADGE_NAME} (${BADGE_SLUG})" - # Step 1: Dispatch to Slack + # Step 1: Dispatch to Slack (Never echoing raw email to stdout) if [ "${IS_DRY_RUN}" = "true" ]; then - echo "[DRY-RUN] Would post to Slack channel ${SLACK_CHANNEL}: /award-badge ${EMAIL} ${BADGE_SLUG}" + echo "[DRY-RUN] Would post award command for ${BADGE_SLUG} to recipient (${MASKED_EMAIL})" else - echo "Dispatching Slack command: /award-badge ${EMAIL} ${BADGE_SLUG}" + echo "Dispatching award for ${BADGE_SLUG} to recipient (${MASKED_EMAIL})..." PAYLOAD=$(jq -n \ - --arg ch "${SLACK_CHANNEL}" \ + --arg ch "${CANONICAL_SLACK_CHANNEL}" \ --arg txt "/award-badge ${EMAIL} ${BADGE_SLUG}" \ '{channel: $ch, text: $txt}') diff --git a/.github/workflows/test-badge-evaluator.yml b/.github/workflows/test-badge-evaluator.yml index d4ab421..fd67e54 100644 --- a/.github/workflows/test-badge-evaluator.yml +++ b/.github/workflows/test-badge-evaluator.yml @@ -15,7 +15,6 @@ on: permissions: contents: read issues: read - pull-requests: read jobs: test-evaluation: @@ -35,9 +34,9 @@ jobs: echo "Evaluating PR #${PR_NUMBER} from external repository: ${TARGET_REPO}..." gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json - gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate > .pr-files.json - gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate > .pr-commits.json - gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate > .existing-labels.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate --slurp | jq 'add // []' > .pr-files.json + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate --slurp | jq 'add // []' > .pr-commits.json + gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate --slurp | jq 'add // []' > .existing-labels.json node -e ' const fs = require("fs"); @@ -66,7 +65,7 @@ jobs: fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, res.summaryMarkdown + "\n"); ' - - name: Print dry-run evaluation report + - name: Print sanitized dry-run report run: | - echo "=== DRY RUN EVALUATION OUTPUT ===" + echo "=== SANITIZED DRY-RUN EVALUATION REPORT ===" cat .evaluation-result.json | jq . diff --git a/utils/award-orchestrator.js b/utils/award-orchestrator.js index 57fec5f..7629d89 100644 --- a/utils/award-orchestrator.js +++ b/utils/award-orchestrator.js @@ -34,12 +34,102 @@ function parseArgs(args) { } /** - * Builds GitHub Actions step summary markdown + * Flattens slurped or paginated API response pages and handles edge cases. + * Handles: + * - Slurped array of pages: [[item1, item2], [item3]] + * - Single flattened page: [item1, item2] + * - Empty array: [] + * - Null or non-array + * + * @param {any} input + * @returns {Array} + */ +function flattenPages(input) { + if (!input) return []; + if (!Array.isArray(input)) return [input]; + if (input.length === 0) return []; + + // Check if first element is an array (slurped page array) + if (Array.isArray(input[0])) { + const flattened = []; + for (const page of input) { + if (Array.isArray(page)) { + flattened.push(...page); + } else if (page) { + flattened.push(page); + } + } + return flattened; + } + return input; +} + +/** + * Deduplicates an array of file objects or strings by filename. + * @param {any} files + * @returns {Array} + */ +function deduplicateFiles(files) { + const seen = new Set(); + const deduped = []; + for (const f of flattenPages(files)) { + const filename = (typeof f === 'string' ? f : (f && f.filename ? f.filename : '')).trim().replace(/\\/g, '/'); + if (filename && !seen.has(filename)) { + seen.add(filename); + deduped.push(f); + } + } + return deduped; +} + +/** + * Deduplicates an array of commit objects by SHA. + * @param {any} commits + * @returns {Array} + */ +function deduplicateCommits(commits) { + const seen = new Set(); + const deduped = []; + for (const c of flattenPages(commits)) { + if (!c) continue; + const sha = (c.sha || '').trim(); + if (sha) { + if (!seen.has(sha)) { + seen.add(sha); + deduped.push(c); + } + } else { + deduped.push(c); + } + } + return deduped; +} + +/** + * Deduplicates an array of labels by name. + * @param {any} labels + * @returns {Array} + */ +function deduplicateLabels(labels) { + const seen = new Set(); + const deduped = []; + for (const l of flattenPages(labels)) { + const name = (typeof l === 'string' ? l : (l && l.name ? l.name : '')).trim().toLowerCase(); + if (name && !seen.has(name)) { + seen.add(name); + deduped.push(l); + } + } + return deduped; +} + +/** + * Builds GitHub Actions step summary markdown. + * Strictly avoids logging plaintext recipient email addresses. */ function buildSummaryMarkdown({ repo, prAuthor, - resolvedEmail, maskedEmail, dcoVerified, dcoReason, @@ -53,7 +143,7 @@ function buildSummaryMarkdown({ lines.push(`- **Target Repository**: \`${repo}\``); lines.push(`- **PR Author**: \`@${prAuthor || 'unknown'}\``); - if (resolvedEmail) { + if (maskedEmail) { lines.push(`- **Recipient Identity**: \`${maskedEmail}\` (${dcoVerified ? '✅ DCO Verified' : '⚠️ DCO Unverified'})`); } else { lines.push(`- **Recipient Identity**: ⚠️ Unresolved email`); @@ -103,8 +193,37 @@ function buildSummaryMarkdown({ return lines.join('\n'); } +/** + * Strips all plaintext email addresses and commands to produce a sanitized public report. + * Safe for step summary, console logging, and dry-run display. + * + * @param {Object} internalResult + * @returns {Object} Sanitized report + */ +function getSanitizedReport(internalResult) { + return { + repo: internalResult.repo, + prAuthor: internalResult.prAuthor, + maskedEmail: internalResult.maskedEmail, + dcoVerified: internalResult.dcoVerified, + dcoReason: internalResult.dcoReason, + allEligibleBadges: internalResult.allEligibleBadges, + alreadyAwardedBadges: internalResult.alreadyAwardedBadges, + unawardedBadges: internalResult.unawardedBadges, + pendingAwards: (internalResult.pendingAwards || []).map(a => ({ + slug: a.slug, + name: a.name, + ruleId: a.ruleId, + reason: a.reason, + trackingLabel: a.trackingLabel + })), + summaryMarkdown: internalResult.summaryMarkdown + }; +} + /** * Orchestrates badge evaluation and award filtering. + * Normalizes multi-page GitHub API responses and ensures strict attribution. * * @param {Object} options * @param {Object} options.prMetadata PR metadata object or file content @@ -129,16 +248,19 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride '' ).trim(); - // Extract labels on PR + // Extract and normalize labels with deduplication across pages const rawPrLabels = prMetadata.labels || (prMetadata.pr && prMetadata.pr.labels) || []; - const normalizedPrLabels = normalizeLabels(rawPrLabels); + const dedupedPrLabels = deduplicateLabels(rawPrLabels); + const normalizedPrLabels = normalizeLabels(dedupedPrLabels); - // Extract files + // Extract and normalize files with deduplication across pages const rawFiles = prMetadata.changedFiles || prMetadata.files || []; - const normalizedFiles = normalizeFiles(rawFiles); + const dedupedFiles = deduplicateFiles(rawFiles); + const normalizedFiles = normalizeFiles(dedupedFiles); - // Extract commits - const commits = prMetadata.commits || []; + // Extract and deduplicate commits across pages + const rawCommits = prMetadata.commits || []; + const dedupedCommits = deduplicateCommits(rawCommits); // Evaluate badge eligibility const { eligibleBadges } = evaluateBadges({ @@ -147,12 +269,15 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride changedFiles: normalizedFiles }); - // Resolve identity and DCO - const identity = resolveIdentity(prAuthor, commits); + // Resolve identity and DCO strictly to PR author + const identity = resolveIdentity(prAuthor, dedupedCommits); const maskedRecipientEmail = maskEmail(identity.resolvedEmail); - // Extract existing tracking labels - const allExistingLabels = normalizeLabels(existingLabels.length > 0 ? existingLabels : rawPrLabels); + // Extract existing tracking labels with deduplication + const sourceExistingLabels = existingLabels.length > 0 ? existingLabels : rawPrLabels; + const dedupedExisting = deduplicateLabels(sourceExistingLabels); + const allExistingLabels = normalizeLabels(dedupedExisting); + const existingTrackingPrefix = 'badge-awarded:'; const alreadyAwardedSlugs = new Set( allExistingLabels @@ -181,7 +306,6 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride const summaryMarkdown = buildSummaryMarkdown({ repo, prAuthor, - resolvedEmail: identity.resolvedEmail, maskedEmail: maskedRecipientEmail, dcoVerified: identity.dcoVerified, dcoReason: identity.reason, @@ -225,11 +349,26 @@ function runCli() { const repoOverride = args.repo || ''; const result = orchestrateAwards({ prMetadata, existingLabels, repoOverride }); + const sanitized = getSanitizedReport(result); + // Write sanitized public output if (args.out) { - fs.writeFileSync(path.resolve(args.out), JSON.stringify(result, null, 2), 'utf-8'); - } else { - process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + fs.writeFileSync(path.resolve(args.out), JSON.stringify(sanitized, null, 2), 'utf-8'); + } + + // Write unlogged dispatch payload if requested (for ephemeral runner step) + if (args['dispatch-out']) { + const dispatchPayload = { + recipientEmail: result.recipientEmail, + maskedEmail: result.maskedEmail, + pendingAwards: result.pendingAwards + }; + fs.writeFileSync(path.resolve(args['dispatch-out']), JSON.stringify(dispatchPayload, null, 2), 'utf-8'); + } + + // If no output file specified, stream sanitized report to stdout + if (!args.out) { + process.stdout.write(JSON.stringify(sanitized, null, 2) + '\n'); } } @@ -240,5 +379,10 @@ if (require.main === module) { module.exports = { orchestrateAwards, parseArgs, - buildSummaryMarkdown + flattenPages, + deduplicateFiles, + deduplicateCommits, + deduplicateLabels, + buildSummaryMarkdown, + getSanitizedReport }; diff --git a/utils/award-orchestrator.test.js b/utils/award-orchestrator.test.js index 924b3a3..e2c73be 100644 --- a/utils/award-orchestrator.test.js +++ b/utils/award-orchestrator.test.js @@ -3,7 +3,15 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { orchestrateAwards, parseArgs } = require('./award-orchestrator'); +const { + orchestrateAwards, + parseArgs, + flattenPages, + deduplicateFiles, + deduplicateCommits, + deduplicateLabels, + getSanitizedReport +} = require('./award-orchestrator'); test('parseArgs parses flags and key-values', () => { const args = ['--metadata=foo.json', '--repo', 'layer5io/sistent', '--dry-run']; @@ -13,6 +21,49 @@ test('parseArgs parses flags and key-values', () => { assert.equal(parsed['dry-run'], 'true'); }); +test('flattenPages and deduplicate handles single, multi, and empty pages', () => { + // Empty responses + assert.deepEqual(flattenPages([]), []); + assert.deepEqual(flattenPages([[]]), []); + assert.deepEqual(flattenPages(null), []); + + // One-page response + const onePageFiles = [{ filename: 'src/button.tsx' }]; + assert.equal(deduplicateFiles(onePageFiles).length, 1); + + // Multi-page slurped response + const multiPageFiles = [ + [{ filename: 'src/button.tsx' }], + [{ filename: 'src/modal.tsx' }] + ]; + const dedupedMulti = deduplicateFiles(multiPageFiles); + assert.equal(dedupedMulti.length, 2); + + // Overlapping duplicates across pages + const overlappingFiles = [ + [{ filename: 'src/button.tsx' }, { filename: 'src/modal.tsx' }], + [{ filename: 'src/button.tsx' }, { filename: 'src/card.tsx' }] + ]; + const dedupedOverlap = deduplicateFiles(overlappingFiles); + assert.equal(dedupedOverlap.length, 3); + + // Overlapping commits + const multiPageCommits = [ + [{ sha: 'sha1', commit: { message: 'first' } }], + [{ sha: 'sha1', commit: { message: 'first duplicate' } }, { sha: 'sha2', commit: { message: 'second' } }] + ]; + const dedupedCommits = deduplicateCommits(multiPageCommits); + assert.equal(dedupedCommits.length, 2); + + // Overlapping labels + const multiPageLabels = [ + [{ name: 'area/ui' }], + [{ name: 'AREA/UI' }, { name: 'enhancement' }] + ]; + const dedupedLabels = deduplicateLabels(multiPageLabels); + assert.equal(dedupedLabels.length, 2); +}); + test('orchestrateAwards produces pending award on qualifying fresh PR', () => { const prMetadata = { repository: 'layer5io/sistent', @@ -23,6 +74,7 @@ test('orchestrateAwards produces pending award on qualifying fresh PR', () => { { author: { login: 'contributor1' }, commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, message: 'feat: add button component\n\nSigned-off-by: Contributor One ' } } @@ -38,7 +90,6 @@ test('orchestrateAwards produces pending award on qualifying fresh PR', () => { assert.equal(result.pendingAwards.length, 1); assert.equal(result.pendingAwards[0].slug, 'sistent-contributor'); assert.equal(result.pendingAwards[0].trackingLabel, 'badge-awarded:sistent-contributor'); - assert.equal(result.pendingAwards[0].slackCommand, '/award-badge contrib@layer5.io sistent-contributor'); assert.equal(result.alreadyAwardedBadges.length, 0); assert.ok(result.summaryMarkdown.includes('Pending Dispatch')); }); @@ -53,6 +104,7 @@ test('orchestrateAwards filters out already awarded badges (Idempotency)', () => { author: { login: 'contributor1' }, commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, message: 'feat: add button\n\nSigned-off-by: Contributor One ' } } @@ -80,6 +132,7 @@ test('orchestrateAwards blocks awards when DCO is unverified', () => { { author: { login: 'author2' }, commit: { + author: { name: 'Author Two', email: 'author2@example.com' }, message: 'fix: update server initialization without dco' } } @@ -94,12 +147,45 @@ test('orchestrateAwards blocks awards when DCO is unverified', () => { assert.ok(result.summaryMarkdown.includes('DCO Blocked')); }); -test('orchestrateAwards CLI file integration works via temp files', () => { +test('Privacy verification: sanitized report and step summary never leak plaintext email', () => { + const plaintextEmail = 'secret.contributor@privatecorp.com'; + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'secretdev', + changedFiles: ['src/index.ts'], + labels: [], + commits: [ + { + author: { login: 'secretdev' }, + commit: { + author: { name: 'Secret Dev', email: plaintextEmail }, + message: `feat: change\n\nSigned-off-by: Secret Dev <${plaintextEmail}>` + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + const sanitized = getSanitizedReport(result); + + // Stringified sanitized report check + const serialized = JSON.stringify(sanitized); + assert.equal(serialized.includes(plaintextEmail), false, 'Sanitized report must never contain plaintext email'); + assert.ok(serialized.includes(sanitized.maskedEmail), 'Sanitized report must contain masked email'); + + // Summary markdown check + assert.equal(result.summaryMarkdown.includes(plaintextEmail), false, 'Summary markdown must never contain plaintext email'); + assert.ok(result.summaryMarkdown.includes(sanitized.maskedEmail)); +}); + +test('orchestrateAwards CLI file integration: separates public report from internal dispatch context', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'award-test-')); const metaFile = path.join(tmpDir, 'pr-meta.json'); const labelsFile = path.join(tmpDir, 'labels.json'); - const outFile = path.join(tmpDir, 'out.json'); + const publicOutFile = path.join(tmpDir, 'sanitized-out.json'); + const dispatchOutFile = path.join(tmpDir, 'dispatch-out.json'); + const plaintextEmail = 'dev3@layer5.io'; const prMetadata = { repository: 'meshery/meshsync', prAuthor: 'dev3', @@ -108,7 +194,8 @@ test('orchestrateAwards CLI file integration works via temp files', () => { { author: { login: 'dev3' }, commit: { - message: 'feat: sync\n\nSigned-off-by: Dev Three ' + author: { name: 'Dev Three', email: plaintextEmail }, + message: `feat: sync\n\nSigned-off-by: Dev Three <${plaintextEmail}>` } } ] @@ -117,20 +204,26 @@ test('orchestrateAwards CLI file integration works via temp files', () => { fs.writeFileSync(metaFile, JSON.stringify(prMetadata), 'utf-8'); fs.writeFileSync(labelsFile, JSON.stringify(['area/sync']), 'utf-8'); - // Programmatic CLI run + // Run CLI const { execFileSync } = require('child_process'); const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); execFileSync(process.execPath, [ scriptPath, `--metadata=${metaFile}`, `--existing-labels=${labelsFile}`, - `--out=${outFile}` + `--out=${publicOutFile}`, + `--dispatch-out=${dispatchOutFile}` ]); - assert.ok(fs.existsSync(outFile)); - const output = JSON.parse(fs.readFileSync(outFile, 'utf-8')); - assert.equal(output.pendingAwards.length, 1); - assert.equal(output.pendingAwards[0].slug, 'meshsync'); + // Public output must be sanitized + assert.ok(fs.existsSync(publicOutFile)); + const publicContent = fs.readFileSync(publicOutFile, 'utf-8'); + assert.equal(publicContent.includes(plaintextEmail), false, 'Public file must not contain raw email'); + + // Dispatch output contains recipient email for runner execution + assert.ok(fs.existsSync(dispatchOutFile)); + const dispatchContent = JSON.parse(fs.readFileSync(dispatchOutFile, 'utf-8')); + assert.equal(dispatchContent.recipientEmail, plaintextEmail); // Clean up fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/utils/badge-evaluator.test.js b/utils/badge-evaluator.test.js index 82aef72..5f4cbd0 100644 --- a/utils/badge-evaluator.test.js +++ b/utils/badge-evaluator.test.js @@ -21,19 +21,27 @@ test('matchGlob utility handles patterns accurately', () => { assert.equal(matchGlob('**/__tests__/**', 'src/__tests__/app.test.js'), true); }); -test('sistent-contributor badge evaluation', () => { - // Qualifying files - const qualifying = evaluateBadges({ +test('sistent-contributor badge evaluation: positive & negative paths', () => { + // Qualifying files in src + const srcResult = evaluateBadges({ repository: 'layer5io/sistent', changedFiles: ['src/components/button.tsx', 'package.json'] }); - assert.equal(qualifying.eligibleBadges.length, 1); - assert.equal(qualifying.eligibleBadges[0].slug, 'sistent-contributor'); + assert.equal(srcResult.eligibleBadges.length, 1); + assert.equal(srcResult.eligibleBadges[0].slug, 'sistent-contributor'); + + // Qualifying files in examples (prevents false negative for demo contributors) + const exampleResult = evaluateBadges({ + repository: 'layer5io/sistent', + changedFiles: ['examples/nextjs-sample/pages/index.tsx'] + }); + assert.equal(exampleResult.eligibleBadges.length, 1); + assert.equal(exampleResult.eligibleBadges[0].slug, 'sistent-contributor'); // Disqualifying root metadata / non-code const disqualified = evaluateBadges({ repository: 'layer5io/sistent', - changedFiles: ['.github/workflows/ci.yml', '.gitignore', 'LICENSE', 'CODE_OF_CONDUCT.md'] + changedFiles: ['.github/workflows/ci.yml', '.gitignore', 'LICENSE', 'CODE_OF_CONDUCT.md', 'README.md', 'CONTRIBUTING.md'] }); assert.equal(disqualified.eligibleBadges.length, 0); @@ -47,7 +55,7 @@ test('sistent-contributor badge evaluation', () => { }); test('meshery core vs meshery-docs evaluation in meshery/meshery', () => { - // Core functional code modification + // Core functional backend code modification const coreResult = evaluateBadges({ repository: 'meshery/meshery', changedFiles: ['server/handlers/patterns.go', 'mesheryctl/cmd/system.go'] @@ -56,6 +64,14 @@ test('meshery core vs meshery-docs evaluation in meshery/meshery', () => { assert.ok(coreSlugs.includes('meshery')); assert.ok(!coreSlugs.includes('meshery-docs')); + // Core functional frontend UI modification (prevents false negative for UI contributors) + const uiResult = evaluateBadges({ + repository: 'meshery/meshery', + changedFiles: ['ui/components/Navigator.tsx'] + }); + const uiSlugs = uiResult.eligibleBadges.map(b => b.slug); + assert.ok(uiSlugs.includes('meshery'), 'UI contributors must earn meshery core badge'); + // Documentation-only modification const docsResult = evaluateBadges({ repository: 'meshery/meshery', @@ -65,10 +81,10 @@ test('meshery core vs meshery-docs evaluation in meshery/meshery', () => { assert.ok(!docsSlugs.includes('meshery'), 'Docs-only PR must not earn meshery core badge'); assert.ok(docsSlugs.includes('meshery-docs'), 'Must earn meshery-docs badge'); - // Root markdown & CI exclusions + // Root markdown & governance exclusions const metaResult = evaluateBadges({ repository: 'meshery/meshery', - changedFiles: ['README.md', 'ROADMAP.md', '.github/workflows/test.yml'] + changedFiles: ['README.md', 'ROADMAP.md', 'ADOPTERS.md', 'GOVERNANCE.md', '.github/workflows/test.yml'] }); assert.equal(metaResult.eligibleBadges.length, 0); @@ -82,7 +98,7 @@ test('meshery core vs meshery-docs evaluation in meshery/meshery', () => { assert.ok(mixedSlugs.includes('meshery-docs')); }); -test('meshery-operator and meshsync badge evaluation', () => { +test('meshery-operator and meshsync badge evaluation: positive & negative paths', () => { // Operator controller modification const opResult = evaluateBadges({ repository: 'meshery/meshery-operator', @@ -91,10 +107,18 @@ test('meshery-operator and meshsync badge evaluation', () => { assert.equal(opResult.eligibleBadges.length, 1); assert.equal(opResult.eligibleBadges[0].slug, 'meshery-operator'); - // MeshSync internal logic modification + // Operator bundle/manifest modification + const opBundle = evaluateBadges({ + repository: 'meshery/meshery-operator', + changedFiles: ['bundle/manifests/meshery.clusterserviceversion.yaml'] + }); + assert.equal(opBundle.eligibleBadges.length, 1); + assert.equal(opBundle.eligibleBadges[0].slug, 'meshery-operator'); + + // MeshSync internal and plugin logic modification const syncResult = evaluateBadges({ repository: 'meshery/meshsync', - changedFiles: ['internal/daemon/sync.go', 'pkg/broker/client.go'] + changedFiles: ['internal/daemon/sync.go', 'plugins/discovery.go'] }); assert.equal(syncResult.eligibleBadges.length, 1); assert.equal(syncResult.eligibleBadges[0].slug, 'meshsync'); @@ -102,7 +126,7 @@ test('meshery-operator and meshsync badge evaluation', () => { // Operator non-code metadata excluded const opMeta = evaluateBadges({ repository: 'meshery/meshery-operator', - changedFiles: ['README.md', 'LICENSE', '.github/workflows/ci.yml'] + changedFiles: ['README.md', 'LICENSE', '.github/workflows/ci.yml', 'CODE_OF_CONDUCT.md'] }); assert.equal(opMeta.eligibleBadges.length, 0); }); diff --git a/utils/badge-rules.json b/utils/badge-rules.json index ca90c5b..7a38254 100644 --- a/utils/badge-rules.json +++ b/utils/badge-rules.json @@ -3,37 +3,54 @@ "slug": "sistent-contributor", "name": "Sistent Contributor", "ruleId": "rule-sistent-contributor", - "description": "Consistent & impactful contributions to Sistent", + "description": "Consistent & impactful contributions to Sistent design system and components", "repositories": ["layer5io/sistent"], "includePatterns": [ "src/**", "packages/**", - "system/**" + "system/**", + "examples/**", + "scripts/**", + "package.json", + "tsconfig.json", + "Makefile" ], "excludePatterns": [ ".github/**", ".gitignore", "LICENSE", - "CODE_OF_CONDUCT.md" + "CODE_OF_CONDUCT.md", + "README.md", + "CONTRIBUTING*.md", + "MAINTAINERS.md" ] }, { "slug": "meshery", "name": "Meshery", "ruleId": "rule-meshery-core", - "description": "Consistent & impactful contributions to Meshery core", + "description": "Consistent & impactful contributions to Meshery core functional codebase", "repositories": ["meshery/meshery"], "includePatterns": [ "server/**", "mesheryctl/**", "models/**", "install/**", - "main.go" + "ui/**", + "provider-ui/**", + "main.go", + "Makefile", + "go.mod", + "go.sum" ], "excludePatterns": [ "docs/**", "README.md", "ROADMAP.md", + "ADOPTERS.md", + "GOVERNANCE.md", + "VISION*.md", + "CONTRIBUTING*.md", ".github/**" ] }, @@ -41,42 +58,54 @@ "slug": "meshery-operator", "name": "Meshery Operator", "ruleId": "rule-meshery-operator", - "description": "Contributions to Meshery Operator", + "description": "Contributions to Meshery Operator controllers, APIs, and manifests", "repositories": ["meshery/meshery-operator"], "includePatterns": [ "controllers/**", "api/**", "pkg/**", - "main.go" + "bundle/**", + "config/**", + "main.go", + "Makefile", + "go.mod", + "go.sum" ], "excludePatterns": [ ".github/**", "LICENSE", - "README.md" + "README.md", + "CODE_OF_CONDUCT.md" ] }, { "slug": "meshsync", "name": "MeshSync", "ruleId": "rule-meshsync", - "description": "Contributions to MeshSync", + "description": "Contributions to MeshSync discovery daemon logic and plugins", "repositories": ["meshery/meshsync"], "includePatterns": [ "internal/**", "pkg/**", - "main.go" + "plugins/**", + "cache/**", + "main.go", + "Makefile", + "go.mod", + "go.sum" ], "excludePatterns": [ ".github/**", "LICENSE", - "README.md" + "README.md", + "CODE_OF_CONDUCT.md" ] }, { "slug": "meshery-docs", "name": "Meshery Docs", "ruleId": "rule-meshery-docs", - "description": "Contributions to documentation", + "description": "Contributions to Meshery documentation trees", "repositories": [ "meshery/meshery", "layer5io/docs" @@ -100,7 +129,7 @@ "slug": "meshery-catalog", "name": "Meshery Catalog", "ruleId": "rule-meshery-catalog", - "description": "Contributions to Meshery Catalog", + "description": "Contributions to Meshery Catalog items and cloud-native models", "repositories": [ "meshery/meshery.io", "meshery/meshery" @@ -124,7 +153,7 @@ "slug": "landscape", "name": "Landscape", "ruleId": "rule-landscape", - "description": "Contributions to Layer5 Landscape", + "description": "Contributions to Layer5 Landscape collection", "repositories": ["layer5io/layer5"], "includePatterns": [ "src/collections/landscape/**" @@ -159,11 +188,14 @@ ], "layer5io/sistent": [ "src/**", - "system/**" + "system/**", + "examples/**" ], "layer5io/layer5": [ "src/components/**", - "src/sections/**" + "src/sections/**", + "src/templates/**", + "src/pages/**" ] }, "excludePatterns": [ diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index a86b3c2..ae8a76a 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -1,3 +1,18 @@ +/** + * Validates an email address against a standard RFC-style pattern. + * Rejects empty strings, missing domain/user parts, missing TLDs, and malformed formats. + * + * @param {string} email + * @returns {boolean} + */ +function isValidEmail(email) { + if (!email || typeof email !== 'string') return false; + const trimmed = email.trim(); + // Standard RFC-style regex requiring valid local part, @, domain label(s), and valid TLD + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; + return emailRegex.test(trimmed); +} + /** * Masks an email address for safe public reporting in step summaries and logs. * Example: "john.doe@example.com" -> "j***e@example.com" @@ -21,8 +36,9 @@ function maskEmail(email) { } /** - * Extracts all Signed-off-by trailers from a commit message. + * Extracts all valid Signed-off-by trailers from a commit message. * Formats supported: "Signed-off-by: First Last " + * Strictly validates trailer syntax and email format. * * @param {string} message Commit message * @returns {Array<{ name: string, email: string }>} @@ -34,25 +50,31 @@ function extractDcoTrailers(message) { let match; while ((match = regex.exec(message)) !== null) { const name = match[1].trim(); - const email = match[2].trim().toLowerCase(); - if (email && email.includes('@')) { - trailers.push({ name, email }); + const rawEmail = match[2].trim(); + if (name && isValidEmail(rawEmail)) { + trailers.push({ name, email: rawEmail.toLowerCase() }); } } return trailers; } /** - * Resolves contributor identity and verifies DCO compliance against commit history. - * Pure function: (authorLogin, commits) -> { resolvedEmail, dcoVerified, reason } + * Resolves contributor identity and strictly verifies DCO compliance against commit history. + * + * Attribution Contract: + * PR Author + * → GitHub-associated commit author matching PR author (strictly commit.author.login === prAuthor) + * → DCO Signed-off-by trailer attributable to that commit author + * → verified email + * * Zero Git or network dependencies. * - * @param {string} authorLogin PR author's GitHub login handle + * @param {string} prAuthor PR author's GitHub login handle * @param {Array} commits List of commit objects (from GitHub API pulls/commits) * @returns {{ resolvedEmail: string|null, dcoVerified: boolean, reason: string }} */ -function resolveIdentity(authorLogin, commits) { - if (!authorLogin || typeof authorLogin !== 'string') { +function resolveIdentity(prAuthor, commits) { + if (!prAuthor || typeof prAuthor !== 'string') { return { resolvedEmail: null, dcoVerified: false, @@ -68,68 +90,131 @@ function resolveIdentity(authorLogin, commits) { }; } - const normalizedAuthor = authorLogin.trim().toLowerCase(); + const normalizedPrAuthor = prAuthor.trim().toLowerCase(); + const commitEmails = []; - // Find commits where GitHub author matches the PR author - const authorCommits = commits.filter(item => { - if (!item) return false; - const commitAuthorLogin = item.author && item.author.login ? item.author.login.trim().toLowerCase() : null; - // Fallback: check committer if author is missing - const commitCommitterLogin = item.committer && item.committer.login ? item.committer.login.trim().toLowerCase() : null; - return commitAuthorLogin === normalizedAuthor || (!commitAuthorLogin && commitCommitterLogin === normalizedAuthor); - }); + for (let idx = 0; idx < commits.length; idx++) { + const item = commits[idx]; + const sha = (item && item.sha ? item.sha.slice(0, 7) : `commit-${idx + 1}`); - if (authorCommits.length === 0) { - return { - resolvedEmail: null, - dcoVerified: false, - reason: `No commits in PR matched GitHub login '${authorLogin}'` - }; - } + if (!item) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Encountered empty commit entry at index ${idx}` + }; + } - const signedEmails = []; - const missingDcoShas = []; + // 1. GitHub-associated author verification (Do NOT fall back to committer) + if (!item.author || !item.author.login) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} lacks a GitHub-associated author account` + }; + } - for (const item of authorCommits) { + const commitAuthorLogin = item.author.login.trim().toLowerCase(); + if (commitAuthorLogin !== normalizedPrAuthor) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} author '@${item.author.login}' does not match PR author '@${prAuthor}'` + }; + } + + // 2. Git commit author metadata + const gitAuthor = item.commit && item.commit.author ? item.commit.author : {}; + const gitAuthorEmail = (gitAuthor.email || '').trim().toLowerCase(); + const gitAuthorName = (gitAuthor.name || '').trim().toLowerCase(); + + // 3. Extract DCO trailers const message = item.commit ? item.commit.message : (item.message || ''); - const sha = (item.sha || 'unknown').slice(0, 7); const trailers = extractDcoTrailers(message); if (trailers.length === 0) { - missingDcoShas.push(sha); + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} is missing a valid DCO Signed-off-by trailer` + }; + } + + // 4. Attributable trailer selection + let attributableTrailer = null; + + if (trailers.length === 1) { + const single = trailers[0]; + // Verify single trailer isn't an arbitrary mismatched third-party + const emailMatches = gitAuthorEmail && single.email === gitAuthorEmail; + const nameMatches = gitAuthorName && single.name.toLowerCase() === gitAuthorName; + const isNoreply = gitAuthorEmail.includes('noreply.github.com'); + + if (emailMatches || nameMatches || isNoreply) { + attributableTrailer = single; + } else { + // Name and email both mismatch git author + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} Signed-off-by trailer '${single.email}' does not match git commit author '${gitAuthorEmail || gitAuthorName}'` + }; + } } else { - // Collect valid email - signedEmails.push(trailers[0].email); + // Multiple trailers: find trailer matching the author's git email or name + const matchingTrailers = trailers.filter(t => { + if (gitAuthorEmail && t.email === gitAuthorEmail) return true; + if (gitAuthorName && t.name.toLowerCase() === gitAuthorName) return true; + return false; + }); + + if (matchingTrailers.length === 1) { + attributableTrailer = matchingTrailers[0]; + } else if (matchingTrailers.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} has multiple Signed-off-by trailers but none match commit author '${gitAuthorEmail || gitAuthorName}'` + }; + } else { + // Multiple trailers claim to match author; check if they share the exact same email + const distinctEmails = [...new Set(matchingTrailers.map(t => t.email))]; + if (distinctEmails.length === 1) { + attributableTrailer = matchingTrailers[0]; + } else { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} has conflicting Signed-off-by trailers for author '${gitAuthorName}'` + }; + } + } } + + commitEmails.push(attributableTrailer.email); } - if (missingDcoShas.length > 0) { - const firstResolvedEmail = signedEmails.length > 0 ? signedEmails[0] : null; + // 5. Verify email consistency across all commits in PR + const distinctEmails = [...new Set(commitEmails)]; + if (distinctEmails.length > 1) { return { - resolvedEmail: firstResolvedEmail, + resolvedEmail: null, dcoVerified: false, - reason: `DCO Signed-off-by trailer missing in ${missingDcoShas.length} commit(s) by ${authorLogin} (e.g. ${missingDcoShas.slice(0, 3).join(', ')})` + reason: `PR contains conflicting Signed-off-by emails across commits (${distinctEmails.map(maskEmail).join(', ')})` }; } - // Count email occurrences to find primary address - const emailCounts = {}; - for (const email of signedEmails) { - emailCounts[email] = (emailCounts[email] || 0) + 1; - } - - const sortedEmails = Object.keys(emailCounts).sort((a, b) => emailCounts[b] - emailCounts[a]); - const primaryEmail = sortedEmails[0] || null; - + const verifiedEmail = distinctEmails[0]; return { - resolvedEmail: primaryEmail, + resolvedEmail: verifiedEmail, dcoVerified: true, - reason: `Verified ${authorCommits.length} commit(s) by ${authorLogin} with valid Signed-off-by trailer` + reason: `Verified ${commits.length} commit(s) by @${prAuthor} with attributable DCO Signed-off-by trailer` }; } module.exports = { - resolveIdentity, + isValidEmail, + maskEmail, extractDcoTrailers, - maskEmail + resolveIdentity }; diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index 42d5676..06577f7 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -1,6 +1,23 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { resolveIdentity, extractDcoTrailers, maskEmail } = require('./identity-resolver'); +const { resolveIdentity, extractDcoTrailers, maskEmail, isValidEmail } = require('./identity-resolver'); + +test('isValidEmail correctly enforces RFC-style structure', () => { + // Rejections + assert.equal(isValidEmail('foo'), false); + assert.equal(isValidEmail('foo@'), false); + assert.equal(isValidEmail('@example.com'), false); + assert.equal(isValidEmail('foo@bar'), false); // Missing valid TLD + assert.equal(isValidEmail('foo@.com'), false); + assert.equal(isValidEmail(''), false); + assert.equal(isValidEmail(null), false); + assert.equal(isValidEmail('user @example.com'), false); + + // Acceptances + assert.equal(isValidEmail('user@example.com'), true); + assert.equal(isValidEmail('contributor.name+tag@sub.domain.co.uk'), true); + assert.equal(isValidEmail('lee@layer5.io'), true); +}); test('maskEmail obfuscates email addresses correctly', () => { assert.equal(maskEmail('john.doe@example.com'), 'j***e@example.com'); @@ -10,15 +27,15 @@ test('maskEmail obfuscates email addresses correctly', () => { assert.equal(maskEmail(null), ''); }); -test('extractDcoTrailers extracts standard trailers', () => { - const msg = `feat(core): add feature\n\nSigned-off-by: Lee Calcote `; +test('extractDcoTrailers extracts and validates standard trailers', () => { + const msg = `feat(core): add feature\n\nSigned-off-by: Lee Calcote \nSigned-off-by: Malformed `; const trailers = extractDcoTrailers(msg); assert.equal(trailers.length, 1); assert.equal(trailers[0].name, 'Lee Calcote'); assert.equal(trailers[0].email, 'lee@layer5.io'); }); -test('resolveIdentity succeeds on verified single commit', () => { +test('resolveIdentity: normal author + matching sign-off', () => { const commits = [ { sha: 'abcdef1234567890', @@ -35,36 +52,94 @@ test('resolveIdentity succeeds on verified single commit', () => { assert.equal(result.resolvedEmail, 'lee@layer5.io'); }); -test('resolveIdentity succeeds with case-insensitive login comparison', () => { +test('resolveIdentity: maintainer sign-off + contributor sign-off (multiple sign-offs)', () => { const commits = [ { - sha: 'abcdef1234567890', - author: { login: 'LeeCalcote' }, + sha: 'squashed12345678', + author: { login: 'contributor1' }, commit: { - author: { name: 'Lee Calcote', email: 'lee@layer5.io' }, - message: 'docs: update readme\n\nSigned-off-by: Lee Calcote ' + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add component\n\nSigned-off-by: Contributor One \nSigned-off-by: Lee Calcote ' } } ]; - const result = resolveIdentity('leecalcote', commits); + const result = resolveIdentity('contributor1', commits); assert.equal(result.dcoVerified, true); - assert.equal(result.resolvedEmail, 'lee@layer5.io'); + assert.equal(result.resolvedEmail, 'contrib@layer5.io'); +}); + +test('resolveIdentity: mismatched sign-off name and email (fails closed)', () => { + const commits = [ + { + sha: 'mismatch12345678', + author: { login: 'alice' }, + commit: { + author: { name: 'Alice Smith', email: 'alice@example.com' }, + message: 'fix: bug\n\nSigned-off-by: Bob Jones ' + } + } + ]; + + const result = resolveIdentity('alice', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('does not match git commit author')); +}); + +test('resolveIdentity: commit author mismatch (commit author != PR author)', () => { + const commits = [ + { + sha: 'authormismatch12', + author: { login: 'mallory' }, + commit: { + author: { name: 'Mallory', email: 'mallory@example.com' }, + message: 'feat: patch\n\nSigned-off-by: Mallory ' + } + } + ]; + + const result = resolveIdentity('alice', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes("does not match PR author '@alice'")); }); -test('resolveIdentity fails DCO if Signed-off-by is absent in any author commit', () => { +test('resolveIdentity: missing GitHub-associated author account (fails closed)', () => { + const commits = [ + { + sha: 'noauthor12345678', + author: null, + committer: { login: 'alice' }, + commit: { + author: { name: 'Alice', email: 'alice@example.com' }, + message: 'feat: patch\n\nSigned-off-by: Alice ' + } + } + ]; + + // Must not fall back to committer + const result = resolveIdentity('alice', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('lacks a GitHub-associated author account')); +}); + +test('resolveIdentity: missing DCO in one of multiple commits (fails closed)', () => { const commits = [ { sha: '1111111111111111', author: { login: 'contributor1' }, commit: { - message: 'first commit\n\nSigned-off-by: Contributor ' + author: { name: 'Contrib', email: 'contrib@test.com' }, + message: 'first commit\n\nSigned-off-by: Contrib ' } }, { sha: '2222222222222222', author: { login: 'contributor1' }, commit: { + author: { name: 'Contrib', email: 'contrib@test.com' }, message: 'second commit without DCO' } } @@ -72,27 +147,49 @@ test('resolveIdentity fails DCO if Signed-off-by is absent in any author commit' const result = resolveIdentity('contributor1', commits); assert.equal(result.dcoVerified, false); - assert.ok(result.reason.includes('DCO Signed-off-by trailer missing')); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('missing a valid DCO Signed-off-by trailer')); }); -test('resolveIdentity fails if no commits belong to the PR author', () => { +test('resolveIdentity: multiple commits with conflicting emails (fails closed)', () => { const commits = [ { - sha: '3333333333333333', - author: { login: 'someoneelse' }, + sha: '1111111111111111', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contrib', email: 'work@test.com' }, + message: 'first commit\n\nSigned-off-by: Contrib ' + } + }, + { + sha: '2222222222222222', + author: { login: 'contributor1' }, commit: { - message: 'commit\n\nSigned-off-by: Someone ' + author: { name: 'Contrib', email: 'personal@test.com' }, + message: 'second commit\n\nSigned-off-by: Contrib ' } } ]; - const result = resolveIdentity('actualAuthor', commits); + const result = resolveIdentity('contributor1', commits); assert.equal(result.dcoVerified, false); assert.equal(result.resolvedEmail, null); - assert.ok(result.reason.includes("No commits in PR matched GitHub login 'actualAuthor'")); + assert.ok(result.reason.includes('conflicting Signed-off-by emails across commits')); }); -test('resolveIdentity gracefully handles empty inputs', () => { - assert.equal(resolveIdentity('', []).dcoVerified, false); - assert.equal(resolveIdentity('user', []).dcoVerified, false); +test('resolveIdentity: squashed commit with multiple sign-offs', () => { + const commits = [ + { + sha: 'squashed99999999', + author: { login: 'dev' }, + commit: { + author: { name: 'Dev User', email: 'dev@company.com' }, + message: 'Squash commit (#42)\n\n* commit 1\n* commit 2\n\nSigned-off-by: Dev User \nSigned-off-by: Reviewer ' + } + } + ]; + + const result = resolveIdentity('dev', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'dev@company.com'); }); diff --git a/utils/workflow-integration.test.js b/utils/workflow-integration.test.js new file mode 100644 index 0000000..ac87b75 --- /dev/null +++ b/utils/workflow-integration.test.js @@ -0,0 +1,171 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +test('Integration: full pipeline with paginated API responses, multi-badge awards, and privacy isolation', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-integration-')); + + const rawFilesPage1 = [ + { filename: 'src/components/Button/index.tsx' } + ]; + const rawFilesPage2 = [ + { filename: 'src/components/Button/index.tsx' }, // duplicate across pages + { filename: 'src/components/Modal/index.tsx' } + ]; + + const rawCommitsPage1 = [ + { + sha: '1111111111111111111111111111111111111111', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add button\n\nSigned-off-by: Contributor One ' + } + } + ]; + const rawCommitsPage2 = [ + { + sha: '2222222222222222222222222222222222222222', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add modal\n\nSigned-off-by: Contributor One \nSigned-off-by: Lee Calcote ' + } + } + ]; + + const rawLabelsPage1 = [{ name: 'area/ui' }]; + const rawLabelsPage2 = [{ name: 'enhancement' }]; + + // Simulate slurped jq add output + const filesSlurped = [rawFilesPage1, rawFilesPage2]; + const commitsSlurped = [rawCommitsPage1, rawCommitsPage2]; + const labelsSlurped = [rawLabelsPage1, rawLabelsPage2]; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + files: filesSlurped, + commits: commitsSlurped, + labels: labelsSlurped + }; + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify(labelsSlurped), 'utf-8'); + + // Execute CLI + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=layer5io/sistent`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + // Verify public file + assert.ok(fs.existsSync(publicOutPath)); + const publicContent = fs.readFileSync(publicOutPath, 'utf-8'); + const publicJson = JSON.parse(publicContent); + + // 1. Privacy check: Plaintext email must NOT exist anywhere in public output + assert.equal(publicContent.includes('contrib@layer5.io'), false); + assert.ok(publicContent.includes('c***b@layer5.io')); + + // 2. Multi-badge evaluation: both sistent-contributor and ui-ux qualified + const awardedSlugs = publicJson.pendingAwards.map(a => a.slug); + assert.ok(awardedSlugs.includes('sistent-contributor'), 'Must award sistent-contributor'); + assert.ok(awardedSlugs.includes('ui-ux'), 'Must award ui-ux'); + assert.equal(publicJson.dcoVerified, true); + + // 3. Dispatch file check: runner has recipient email + assert.ok(fs.existsSync(dispatchOutPath)); + const dispatchJson = JSON.parse(fs.readFileSync(dispatchOutPath, 'utf-8')); + assert.equal(dispatchJson.recipientEmail, 'contrib@layer5.io'); + assert.equal(dispatchJson.pendingAwards.length, 2); + + // 4. Idempotency on rerun with tracking labels + const rerunLabelsPath = path.join(tmpDir, 'existing-labels-rerun.json'); + const existingWithLabels = [ + { name: 'area/ui' }, + { name: 'badge-awarded:sistent-contributor' } + ]; + fs.writeFileSync(rerunLabelsPath, JSON.stringify(existingWithLabels), 'utf-8'); + + const rerunPublicOut = path.join(tmpDir, 'rerun-evaluation-result.json'); + const rerunDispatchOut = path.join(tmpDir, 'rerun-dispatch-context.json'); + + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${rerunLabelsPath}`, + `--repo=layer5io/sistent`, + `--out=${rerunPublicOut}`, + `--dispatch-out=${rerunDispatchOut}` + ]); + + const rerunPublicJson = JSON.parse(fs.readFileSync(rerunPublicOut, 'utf-8')); + const rerunSlugs = rerunPublicJson.pendingAwards.map(a => a.slug); + assert.ok(!rerunSlugs.includes('sistent-contributor'), 'Already awarded badge must be excluded on rerun'); + assert.ok(rerunSlugs.includes('ui-ux'), 'Unawarded badge must remain pending'); + + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('Integration: missing DCO blocks award dispatch in pipeline', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-nodco-')); + + const prMetadata = { + repository: 'meshery/meshery', + prAuthor: 'author1', + files: [{ filename: 'server/main.go' }], + commits: [ + { + sha: 'abc1234', + author: { login: 'author1' }, + commit: { + author: { name: 'Author', email: 'author@test.com' }, + message: 'commit without dco' + } + } + ], + labels: [] + }; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify([]), 'utf-8'); + + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=meshery/meshery`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + const publicJson = JSON.parse(fs.readFileSync(publicOutPath, 'utf-8')); + assert.equal(publicJson.dcoVerified, false); + assert.equal(publicJson.pendingAwards.length, 0); + + const dispatchJson = JSON.parse(fs.readFileSync(dispatchOutPath, 'utf-8')); + assert.equal(dispatchJson.pendingAwards.length, 0); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); From 5237a1e80699890a407c528e8e5647053c81efaa Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 18 Sep 2026 20:07:15 +0000 Subject: [PATCH 03/12] fix(resolver): scope DCO validation strictly to PR-author commits - Filter PR commit list to commits whose GitHub author matches PR author - Allow PR commit histories containing maintainer/co-contributor commits - Fail closed when no PR-author commit matches a valid DCO sign-off - Add regression test for author signed commit plus non-author commit Signed-off-by: Parth Gartan --- utils/identity-resolver.js | 60 ++++++++++++++------------------- utils/identity-resolver.test.js | 42 ++++++++++++++++++----- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index ae8a76a..d600ef5 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -63,10 +63,13 @@ function extractDcoTrailers(message) { * * Attribution Contract: * PR Author - * → GitHub-associated commit author matching PR author (strictly commit.author.login === prAuthor) + * → Filter PR commits to those whose GitHub-associated author.login matches PR author * → DCO Signed-off-by trailer attributable to that commit author * → verified email * + * Commits authored by maintainers/other contributors in the PR do not fail attribution. + * When no PR-author commit can be matched to a valid DCO sign-off, fails closed. + * * Zero Git or network dependencies. * * @param {string} prAuthor PR author's GitHub login handle @@ -91,44 +94,33 @@ function resolveIdentity(prAuthor, commits) { } const normalizedPrAuthor = prAuthor.trim().toLowerCase(); - const commitEmails = []; - for (let idx = 0; idx < commits.length; idx++) { - const item = commits[idx]; - const sha = (item && item.sha ? item.sha.slice(0, 7) : `commit-${idx + 1}`); + // Filter commits strictly to those whose GitHub-associated author matches the PR author + const authorCommits = commits.filter(item => { + if (!item || !item.author || !item.author.login) return false; + return item.author.login.trim().toLowerCase() === normalizedPrAuthor; + }); - if (!item) { - return { - resolvedEmail: null, - dcoVerified: false, - reason: `Encountered empty commit entry at index ${idx}` - }; - } + if (authorCommits.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `No commits in PR matched GitHub-associated author '@${prAuthor}'` + }; + } - // 1. GitHub-associated author verification (Do NOT fall back to committer) - if (!item.author || !item.author.login) { - return { - resolvedEmail: null, - dcoVerified: false, - reason: `Commit ${sha} lacks a GitHub-associated author account` - }; - } + const commitEmails = []; - const commitAuthorLogin = item.author.login.trim().toLowerCase(); - if (commitAuthorLogin !== normalizedPrAuthor) { - return { - resolvedEmail: null, - dcoVerified: false, - reason: `Commit ${sha} author '@${item.author.login}' does not match PR author '@${prAuthor}'` - }; - } + for (let idx = 0; idx < authorCommits.length; idx++) { + const item = authorCommits[idx]; + const sha = (item && item.sha ? item.sha.slice(0, 7) : `commit-${idx + 1}`); - // 2. Git commit author metadata + // Git commit author metadata const gitAuthor = item.commit && item.commit.author ? item.commit.author : {}; const gitAuthorEmail = (gitAuthor.email || '').trim().toLowerCase(); const gitAuthorName = (gitAuthor.name || '').trim().toLowerCase(); - // 3. Extract DCO trailers + // Extract DCO trailers const message = item.commit ? item.commit.message : (item.message || ''); const trailers = extractDcoTrailers(message); @@ -136,11 +128,11 @@ function resolveIdentity(prAuthor, commits) { return { resolvedEmail: null, dcoVerified: false, - reason: `Commit ${sha} is missing a valid DCO Signed-off-by trailer` + reason: `Commit ${sha} by @${prAuthor} is missing a valid DCO Signed-off-by trailer` }; } - // 4. Attributable trailer selection + // Attributable trailer selection let attributableTrailer = null; if (trailers.length === 1) { @@ -194,7 +186,7 @@ function resolveIdentity(prAuthor, commits) { commitEmails.push(attributableTrailer.email); } - // 5. Verify email consistency across all commits in PR + // Verify email consistency across all PR-author commits const distinctEmails = [...new Set(commitEmails)]; if (distinctEmails.length > 1) { return { @@ -208,7 +200,7 @@ function resolveIdentity(prAuthor, commits) { return { resolvedEmail: verifiedEmail, dcoVerified: true, - reason: `Verified ${commits.length} commit(s) by @${prAuthor} with attributable DCO Signed-off-by trailer` + reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with attributable DCO Signed-off-by trailer` }; } diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index 06577f7..fda5e72 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -52,7 +52,7 @@ test('resolveIdentity: normal author + matching sign-off', () => { assert.equal(result.resolvedEmail, 'lee@layer5.io'); }); -test('resolveIdentity: maintainer sign-off + contributor sign-off (multiple sign-offs)', () => { +test('resolveIdentity: maintainer sign-off + contributor sign-off on same commit', () => { const commits = [ { sha: 'squashed12345678', @@ -69,7 +69,33 @@ test('resolveIdentity: maintainer sign-off + contributor sign-off (multiple sign assert.equal(result.resolvedEmail, 'contrib@layer5.io'); }); -test('resolveIdentity: mismatched sign-off name and email (fails closed)', () => { +test('resolveIdentity: author signed commit plus non-author / maintainer commit in PR', () => { + const commits = [ + { + sha: 'auth111111111111', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: implement feature\n\nSigned-off-by: Contributor One ' + } + }, + { + sha: 'maint22222222222', + author: { login: 'maintainerA' }, + commit: { + author: { name: 'Maintainer A', email: 'maintainer@layer5.io' }, + message: 'chore: merge master into branch\n\nSigned-off-by: Maintainer A ' + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'contrib@layer5.io'); + assert.ok(result.reason.includes('Verified 1 commit(s) by @contributor1')); +}); + +test('resolveIdentity: mismatched sign-off name and email on author commit (fails closed)', () => { const commits = [ { sha: 'mismatch12345678', @@ -87,7 +113,7 @@ test('resolveIdentity: mismatched sign-off name and email (fails closed)', () => assert.ok(result.reason.includes('does not match git commit author')); }); -test('resolveIdentity: commit author mismatch (commit author != PR author)', () => { +test('resolveIdentity: commit author mismatch when no commits belong to PR author (fails closed)', () => { const commits = [ { sha: 'authormismatch12', @@ -102,7 +128,7 @@ test('resolveIdentity: commit author mismatch (commit author != PR author)', () const result = resolveIdentity('alice', commits); assert.equal(result.dcoVerified, false); assert.equal(result.resolvedEmail, null); - assert.ok(result.reason.includes("does not match PR author '@alice'")); + assert.ok(result.reason.includes("No commits in PR matched GitHub-associated author '@alice'")); }); test('resolveIdentity: missing GitHub-associated author account (fails closed)', () => { @@ -122,10 +148,10 @@ test('resolveIdentity: missing GitHub-associated author account (fails closed)', const result = resolveIdentity('alice', commits); assert.equal(result.dcoVerified, false); assert.equal(result.resolvedEmail, null); - assert.ok(result.reason.includes('lacks a GitHub-associated author account')); + assert.ok(result.reason.includes("No commits in PR matched GitHub-associated author '@alice'")); }); -test('resolveIdentity: missing DCO in one of multiple commits (fails closed)', () => { +test('resolveIdentity: missing DCO in one of author commits (fails closed)', () => { const commits = [ { sha: '1111111111111111', @@ -148,10 +174,10 @@ test('resolveIdentity: missing DCO in one of multiple commits (fails closed)', ( const result = resolveIdentity('contributor1', commits); assert.equal(result.dcoVerified, false); assert.equal(result.resolvedEmail, null); - assert.ok(result.reason.includes('missing a valid DCO Signed-off-by trailer')); + assert.ok(result.reason.includes('is missing a valid DCO Signed-off-by trailer')); }); -test('resolveIdentity: multiple commits with conflicting emails (fails closed)', () => { +test('resolveIdentity: multiple author commits with conflicting emails (fails closed)', () => { const commits = [ { sha: '1111111111111111', From ba10ac4d140461529bcdf67c35dad0feddba2c60 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 18 Sep 2026 20:17:35 +0000 Subject: [PATCH 04/12] 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 --- .github/workflows/award-project-badge.yml | 54 ++++++++- .github/workflows/test-badge-evaluator.yml | 43 ++++++- utils/award-orchestrator.js | 79 ++++++++++++- utils/award-orchestrator.test.js | 45 ++++++++ utils/badge-evaluator.js | 38 ++++++- utils/identity-resolver.js | 124 +++++++++++---------- utils/identity-resolver.test.js | 109 ++++++++++++++++-- utils/workflow-integration.test.js | 96 ++++++++++++++++ 8 files changed, 502 insertions(+), 86 deletions(-) diff --git a/.github/workflows/award-project-badge.yml b/.github/workflows/award-project-badge.yml index 5c1c1c0..69d7af2 100644 --- a/.github/workflows/award-project-badge.yml +++ b/.github/workflows/award-project-badge.yml @@ -30,6 +30,22 @@ jobs: name: Evaluate and Award Badges runs-on: ubuntu-latest steps: + - name: Validate authorized repository allowlist + env: + TARGET_REPO: ${{ github.repository }} + run: | + set -euo pipefail + NORMALIZED_REPO=$(echo "${TARGET_REPO}" | tr '[:upper:]' '[:lower:]') + case "${NORMALIZED_REPO}" in + "layer5io/sistent"|"meshery/meshery"|"meshery/meshery-operator"|"meshery/meshsync"|"layer5io/docs"|"meshery/meshery.io"|"layer5io/layer5") + echo "Repository '${TARGET_REPO}' is authorized for Track 2 badge automation." + ;; + *) + echo "::error::Repository '${TARGET_REPO}' is not an authorized Track 2 participating repository. Failing workflow." + exit 1 + ;; + esac + - name: Checkout trusted recognition engine uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA with: @@ -39,16 +55,34 @@ jobs: sparse-checkout: | utils - - name: Collect PR metadata + - name: Verify PR status and collect metadata + id: collect-meta env: GH_TOKEN: ${{ github.token }} TARGET_REPO: ${{ github.repository }} PR_NUMBER: ${{ inputs.pr_number }} run: | set -euo pipefail - echo "Collecting metadata for PR #${PR_NUMBER} in ${TARGET_REPO}..." + echo "Fetching Pull Request #${PR_NUMBER} in ${TARGET_REPO}..." - gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json + if ! gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2>/dev/null; then + echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}. Exiting without dispatch." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`. Zero badges awarded." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + IS_MERGED=$(jq -r '.merged // false' .pr-info.json) + if [ "${IS_MERGED}" != "true" ]; then + echo "::warning::Pull Request #${PR_NUMBER} in ${TARGET_REPO} is not merged (merged=${IS_MERGED}). Skipping badge evaluation." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} in \`${TARGET_REPO}\` is not in a merged state. Zero badges awarded." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Pull Request #${PR_NUMBER} verified as merged. Fetching files, commits, and labels..." gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate --slurp | jq 'add // []' > .pr-files.json gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate --slurp | jq 'add // []' > .pr-commits.json gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate --slurp | jq 'add // []' > .existing-labels.json @@ -60,8 +94,10 @@ jobs: const commits = JSON.parse(fs.readFileSync(".pr-commits.json")); fs.writeFileSync(".pr-metadata.json", JSON.stringify({ pr, files, commits }, null, 2)); ' + echo "skip=false" >> "$GITHUB_OUTPUT" - name: Run badge award orchestrator + if: ${{ steps.collect-meta.outputs.skip == 'false' }} env: TARGET_REPO: ${{ github.repository }} run: | @@ -74,6 +110,7 @@ jobs: --dispatch-out=".dispatch-context.json" - name: Publish evaluation step summary + if: ${{ steps.collect-meta.outputs.skip == 'false' }} run: | node -e ' const fs = require("fs"); @@ -82,7 +119,7 @@ jobs: ' - name: Verify Slack credentials for production run - if: ${{ inputs.dry_run == false }} + if: ${{ steps.collect-meta.outputs.skip == 'false' && inputs.dry_run == false }} env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} run: | @@ -98,6 +135,7 @@ jobs: fi - name: Sequentially dispatch awards and apply tracking labels + if: ${{ steps.collect-meta.outputs.skip == 'false' }} env: GH_TOKEN: ${{ github.token }} TARGET_REPO: ${{ github.repository }} @@ -196,17 +234,23 @@ jobs: echo "Label '${LABEL_NAME}' already exists (race condition resolved)." else echo "::error::Fatal 422 error creating label '${LABEL_NAME}': $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" exit 1 fi else echo "::error::Failed to create label '${LABEL_NAME}' (HTTP ${HTTP_CODE}): $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" exit 1 fi + rm -f "${CREATE_RESP_FILE}" fi # Apply label to PR echo "Applying tracking label '${LABEL_NAME}' to PR #${PR_NUMBER}..." - gh issue edit "${PR_NUMBER}" --repo "${TARGET_REPO}" --add-label "${LABEL_NAME}" + if ! gh issue edit "${PR_NUMBER}" --repo "${TARGET_REPO}" --add-label "${LABEL_NAME}"; then + echo "::error::Slack award dispatched for badge '${BADGE_SLUG}' but GitHub label application failed. Manual tracking label intervention required." + exit 1 + fi echo "Applied tracking label '${LABEL_NAME}'." fi done diff --git a/.github/workflows/test-badge-evaluator.yml b/.github/workflows/test-badge-evaluator.yml index fd67e54..9c488f3 100644 --- a/.github/workflows/test-badge-evaluator.yml +++ b/.github/workflows/test-badge-evaluator.yml @@ -21,19 +21,52 @@ jobs: name: Dry-Run Historical PR Evaluation runs-on: ubuntu-latest steps: + - name: Validate authorized repository allowlist + env: + TARGET_REPO: ${{ inputs.repository }} + run: | + set -euo pipefail + NORMALIZED_REPO=$(echo "${TARGET_REPO}" | tr '[:upper:]' '[:lower:]') + case "${NORMALIZED_REPO}" in + "layer5io/sistent"|"meshery/meshery"|"meshery/meshery-operator"|"meshery/meshsync"|"layer5io/docs"|"meshery/meshery.io"|"layer5io/layer5") + echo "Repository '${TARGET_REPO}' is authorized for Track 2 badge automation." + ;; + *) + echo "::error::Repository '${TARGET_REPO}' is not an authorized Track 2 participating repository." + exit 1 + ;; + esac + - name: Checkout recognition repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA - - name: Collect target PR metadata + - name: Verify PR status and collect metadata + id: collect-meta env: GH_TOKEN: ${{ github.token }} TARGET_REPO: ${{ inputs.repository }} PR_NUMBER: ${{ inputs.pr_number }} run: | set -euo pipefail - echo "Evaluating PR #${PR_NUMBER} from external repository: ${TARGET_REPO}..." + echo "Evaluating PR #${PR_NUMBER} from repository: ${TARGET_REPO}..." + + if ! gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2>/dev/null; then + echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + IS_MERGED=$(jq -r '.merged // false' .pr-info.json) + if [ "${IS_MERGED}" != "true" ]; then + echo "::warning::Pull Request #${PR_NUMBER} in ${TARGET_REPO} is not in a merged state (merged=${IS_MERGED})." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} in \`${TARGET_REPO}\` is not in a merged state." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi - gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/files" --paginate --slurp | jq 'add // []' > .pr-files.json gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}/commits" --paginate --slurp | jq 'add // []' > .pr-commits.json gh api "repos/${TARGET_REPO}/issues/${PR_NUMBER}/labels" --paginate --slurp | jq 'add // []' > .existing-labels.json @@ -45,8 +78,10 @@ jobs: const commits = JSON.parse(fs.readFileSync(".pr-commits.json")); fs.writeFileSync(".pr-metadata.json", JSON.stringify({ pr, files, commits }, null, 2)); ' + echo "skip=false" >> "$GITHUB_OUTPUT" - name: Run award orchestrator in dry-run mode + if: ${{ steps.collect-meta.outputs.skip == 'false' }} env: TARGET_REPO: ${{ inputs.repository }} run: | @@ -58,6 +93,7 @@ jobs: --out=".evaluation-result.json" - name: Publish step summary + if: ${{ steps.collect-meta.outputs.skip == 'false' }} run: | node -e ' const fs = require("fs"); @@ -66,6 +102,7 @@ jobs: ' - name: Print sanitized dry-run report + if: ${{ steps.collect-meta.outputs.skip == 'false' }} run: | echo "=== SANITIZED DRY-RUN EVALUATION REPORT ===" cat .evaluation-result.json | jq . diff --git a/utils/award-orchestrator.js b/utils/award-orchestrator.js index 7629d89..e5aa666 100644 --- a/utils/award-orchestrator.js +++ b/utils/award-orchestrator.js @@ -1,6 +1,12 @@ const fs = require('fs'); const path = require('path'); -const { evaluateBadges, normalizeLabels, normalizeFiles } = require('./badge-evaluator'); +const { + evaluateBadges, + normalizeLabels, + normalizeFiles, + isSupportedRepository, + SUPPORTED_REPOSITORIES +} = require('./badge-evaluator'); const { resolveIdentity, maskEmail } = require('./identity-resolver'); /** @@ -135,7 +141,9 @@ function buildSummaryMarkdown({ dcoReason, allEligibleBadges, alreadyAwardedBadges, - pendingAwards + pendingAwards, + isSupportedRepo, + isMerged }) { const lines = []; lines.push(`## 🎖️ Contributor Badge Evaluation Summary`); @@ -143,6 +151,22 @@ function buildSummaryMarkdown({ lines.push(`- **Target Repository**: \`${repo}\``); lines.push(`- **PR Author**: \`@${prAuthor || 'unknown'}\``); + if (!isSupportedRepo) { + lines.push(''); + lines.push(`> [!WARNING]`); + lines.push(`> Repository \`${repo}\` is not an authorized Track 2 participating repository. Badge evaluation rejected.`); + lines.push(''); + return lines.join('\n'); + } + + if (isMerged === false) { + lines.push(''); + lines.push(`> [!WARNING]`); + lines.push(`> Pull request is not in a merged state. Badge assignment is strictly limited to merged pull requests.`); + lines.push(''); + return lines.join('\n'); + } + if (maskedEmail) { lines.push(`- **Recipient Identity**: \`${maskedEmail}\` (${dcoVerified ? '✅ DCO Verified' : '⚠️ DCO Unverified'})`); } else { @@ -207,6 +231,8 @@ function getSanitizedReport(internalResult) { maskedEmail: internalResult.maskedEmail, dcoVerified: internalResult.dcoVerified, dcoReason: internalResult.dcoReason, + isSupportedRepo: internalResult.isSupportedRepo, + isMerged: internalResult.isMerged, allEligibleBadges: internalResult.allEligibleBadges, alreadyAwardedBadges: internalResult.alreadyAwardedBadges, unawardedBadges: internalResult.unawardedBadges, @@ -241,6 +267,17 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride '' ).trim(); + // Validate supported repository allowlist + const isSupportedRepo = isSupportedRepository(repo); + + // Validate merged status if present in metadata + let isMerged = true; + if (prMetadata.pr && typeof prMetadata.pr.merged === 'boolean') { + isMerged = prMetadata.pr.merged; + } else if (typeof prMetadata.merged === 'boolean') { + isMerged = prMetadata.merged; + } + // Extract author const prAuthor = ( prMetadata.prAuthor || @@ -262,6 +299,38 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride const rawCommits = prMetadata.commits || []; const dedupedCommits = deduplicateCommits(rawCommits); + // If repository is unsupported or PR is unmerged, fail closed immediately + if (!isSupportedRepo || isMerged === false) { + const summaryMarkdown = buildSummaryMarkdown({ + repo, + prAuthor, + maskedEmail: '', + dcoVerified: false, + dcoReason: !isSupportedRepo ? 'Unsupported repository' : 'PR is not merged', + allEligibleBadges: [], + alreadyAwardedBadges: [], + pendingAwards: [], + isSupportedRepo, + isMerged + }); + + return { + repo, + prAuthor, + recipientEmail: null, + maskedEmail: '', + dcoVerified: false, + dcoReason: !isSupportedRepo ? 'Unsupported repository' : 'PR is not merged', + isSupportedRepo, + isMerged, + allEligibleBadges: [], + alreadyAwardedBadges: [], + unawardedBadges: [], + pendingAwards: [], + summaryMarkdown + }; + } + // Evaluate badge eligibility const { eligibleBadges } = evaluateBadges({ repository: repo, @@ -311,7 +380,9 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride dcoReason: identity.reason, allEligibleBadges: eligibleBadges, alreadyAwardedBadges, - pendingAwards + pendingAwards, + isSupportedRepo, + isMerged }); return { @@ -321,6 +392,8 @@ function orchestrateAwards({ prMetadata = {}, existingLabels = [], repoOverride maskedEmail: maskedRecipientEmail, dcoVerified: identity.dcoVerified, dcoReason: identity.reason, + isSupportedRepo, + isMerged, allEligibleBadges: eligibleBadges, alreadyAwardedBadges, unawardedBadges, diff --git a/utils/award-orchestrator.test.js b/utils/award-orchestrator.test.js index e2c73be..f1efce4 100644 --- a/utils/award-orchestrator.test.js +++ b/utils/award-orchestrator.test.js @@ -94,6 +94,51 @@ test('orchestrateAwards produces pending award on qualifying fresh PR', () => { assert.ok(result.summaryMarkdown.includes('Pending Dispatch')); }); +test('orchestrateAwards fails closed on unauthorized / unexpected repositories', () => { + const prMetadata = { + repository: 'malicious-org/arbitrary-repo', + prAuthor: 'hacker', + changedFiles: ['src/index.ts'], + commits: [ + { + author: { login: 'hacker' }, + commit: { + author: { name: 'Hacker', email: 'hacker@example.com' }, + message: 'exploit\n\nSigned-off-by: Hacker ' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + assert.equal(result.isSupportedRepo, false); + assert.equal(result.pendingAwards.length, 0); + assert.ok(result.summaryMarkdown.includes('not an authorized Track 2 participating repository')); +}); + +test('orchestrateAwards fails closed on unmerged pull requests (merged guard)', () => { + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + merged: false, // Unmerged PR + changedFiles: ['src/button.tsx'], + commits: [ + { + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contrib', email: 'contrib@layer5.io' }, + message: 'feat: button\n\nSigned-off-by: Contrib ' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + assert.equal(result.isMerged, false); + assert.equal(result.pendingAwards.length, 0); + assert.ok(result.summaryMarkdown.includes('not in a merged state')); +}); + test('orchestrateAwards filters out already awarded badges (Idempotency)', () => { const prMetadata = { repository: 'layer5io/sistent', diff --git a/utils/badge-evaluator.js b/utils/badge-evaluator.js index de421e7..1d5bc78 100644 --- a/utils/badge-evaluator.js +++ b/utils/badge-evaluator.js @@ -1,5 +1,28 @@ const defaultRules = require('./badge-rules.json'); +/** + * Authoritative allowlist of participating Track 2 ecosystem repositories. + */ +const SUPPORTED_REPOSITORIES = Object.freeze([ + 'layer5io/sistent', + 'meshery/meshery', + 'meshery/meshery-operator', + 'meshery/meshsync', + 'layer5io/docs', + 'meshery/meshery.io', + 'layer5io/layer5' +]); + +/** + * Validates whether a repository name is an authorized Track 2 participating repository. + * @param {string} repository + * @returns {boolean} + */ +function isSupportedRepository(repository) { + if (!repository || typeof repository !== 'string') return false; + return SUPPORTED_REPOSITORIES.includes(repository.trim().toLowerCase()); +} + /** * Matches a glob pattern against a normalized relative file path. * Supports: @@ -85,7 +108,7 @@ function normalizeFiles(files) { /** * Evaluates a pull request's metadata against badge rules. - * Pure function: (repo, labels, changedFiles, rules) -> { eligibleBadges: [ { slug, name, reason, ruleId } ] } + * Pure function: (repo, labels, changedFiles, rules) -> { eligibleBadges: [ { slug, name, reason, ruleId } ], isSupportedRepo: boolean } * Zero Git or network dependencies. * * @param {Object} prContext @@ -93,7 +116,7 @@ function normalizeFiles(files) { * @param {Array} [prContext.labels] PR labels * @param {Array} [prContext.changedFiles] List of changed files * @param {Array} [rules] Optional badge rules override - * @returns {{ eligibleBadges: Array<{ slug: string, name: string, reason: string, ruleId: string }> }} + * @returns {{ eligibleBadges: Array<{ slug: string, name: string, reason: string, ruleId: string }>, isSupportedRepo: boolean }} */ function evaluateBadges(prContext = {}, rules = defaultRules) { const repository = (prContext.repository || prContext.repo || '').trim().toLowerCase(); @@ -101,7 +124,12 @@ function evaluateBadges(prContext = {}, rules = defaultRules) { const rawFiles = normalizeFiles(prContext.changedFiles || prContext.files); if (!repository) { - return { eligibleBadges: [] }; + return { eligibleBadges: [], isSupportedRepo: false }; + } + + const isSupported = isSupportedRepository(repository); + if (!isSupported) { + return { eligibleBadges: [], isSupportedRepo: false }; } const eligibleBadges = []; @@ -160,10 +188,12 @@ function evaluateBadges(prContext = {}, rules = defaultRules) { } } - return { eligibleBadges }; + return { eligibleBadges, isSupportedRepo: true }; } module.exports = { + SUPPORTED_REPOSITORIES, + isSupportedRepository, evaluateBadges, matchGlob, normalizeLabels, diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index d600ef5..9dab1df 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -14,7 +14,7 @@ function isValidEmail(email) { } /** - * Masks an email address for safe public reporting in step summaries and logs. + * Masks an email address for safe public reporting in step summaries, logs, and diagnostics. * Example: "john.doe@example.com" -> "j***e@example.com" * * @param {string} email @@ -58,19 +58,52 @@ function extractDcoTrailers(message) { return trailers; } +/** + * Determines whether a DCO trailer is deterministically attributable to the author of a commit. + * + * Deterministic Matching Rules: + * 1. Direct email match: trailer email strictly matches git commit author email. + * 2. Noreply with name match: if git commit author email is a GitHub noreply address, + * the trailer name MUST match the git commit author's name. + * (An arbitrary DCO trailer is NEVER accepted merely because the git author used a noreply email). + * + * @param {Object} trailer { name: string, email: string } + * @param {Object} gitAuthor { name: string, email: string } + * @returns {boolean} + */ +function isTrailerAttributableToAuthor(trailer, gitAuthor) { + if (!trailer || !gitAuthor) return false; + + const tEmail = (trailer.email || '').trim().toLowerCase(); + const tName = (trailer.name || '').trim().toLowerCase(); + const gitEmail = (gitAuthor.email || '').trim().toLowerCase(); + const gitName = (gitAuthor.name || '').trim().toLowerCase(); + + // Rule 1: Direct git commit author email match + if (gitEmail && tEmail === gitEmail) { + return true; + } + + // Rule 2: GitHub noreply email requiring strict git author name match + const isNoreply = gitEmail.endsWith('@users.noreply.github.com') || gitEmail.includes('noreply.github.com'); + if (isNoreply && gitName && tName === gitName) { + return true; + } + + return false; +} + /** * Resolves contributor identity and strictly verifies DCO compliance against commit history. * * Attribution Contract: * PR Author - * → Filter PR commits to those whose GitHub-associated author.login matches PR author - * → DCO Signed-off-by trailer attributable to that commit author - * → verified email + * → Filter PR commits strictly to those whose GitHub-associated author.login matches PR author + * → DCO Signed-off-by trailer deterministically attributable to that commit author + * → verified RFC-compliant email * - * Commits authored by maintainers/other contributors in the PR do not fail attribution. - * When no PR-author commit can be matched to a valid DCO sign-off, fails closed. - * - * Zero Git or network dependencies. + * Privacy Invariant: + * The returned reason string NEVER contains plaintext contributor email addresses. * * @param {string} prAuthor PR author's GitHub login handle * @param {Array} commits List of commit objects (from GitHub API pulls/commits) @@ -117,8 +150,6 @@ function resolveIdentity(prAuthor, commits) { // Git commit author metadata const gitAuthor = item.commit && item.commit.author ? item.commit.author : {}; - const gitAuthorEmail = (gitAuthor.email || '').trim().toLowerCase(); - const gitAuthorName = (gitAuthor.name || '').trim().toLowerCase(); // Extract DCO trailers const message = item.commit ? item.commit.message : (item.message || ''); @@ -132,58 +163,28 @@ function resolveIdentity(prAuthor, commits) { }; } - // Attributable trailer selection - let attributableTrailer = null; - - if (trailers.length === 1) { - const single = trailers[0]; - // Verify single trailer isn't an arbitrary mismatched third-party - const emailMatches = gitAuthorEmail && single.email === gitAuthorEmail; - const nameMatches = gitAuthorName && single.name.toLowerCase() === gitAuthorName; - const isNoreply = gitAuthorEmail.includes('noreply.github.com'); - - if (emailMatches || nameMatches || isNoreply) { - attributableTrailer = single; - } else { - // Name and email both mismatch git author - return { - resolvedEmail: null, - dcoVerified: false, - reason: `Commit ${sha} Signed-off-by trailer '${single.email}' does not match git commit author '${gitAuthorEmail || gitAuthorName}'` - }; - } - } else { - // Multiple trailers: find trailer matching the author's git email or name - const matchingTrailers = trailers.filter(t => { - if (gitAuthorEmail && t.email === gitAuthorEmail) return true; - if (gitAuthorName && t.name.toLowerCase() === gitAuthorName) return true; - return false; - }); - - if (matchingTrailers.length === 1) { - attributableTrailer = matchingTrailers[0]; - } else if (matchingTrailers.length === 0) { - return { - resolvedEmail: null, - dcoVerified: false, - reason: `Commit ${sha} has multiple Signed-off-by trailers but none match commit author '${gitAuthorEmail || gitAuthorName}'` - }; - } else { - // Multiple trailers claim to match author; check if they share the exact same email - const distinctEmails = [...new Set(matchingTrailers.map(t => t.email))]; - if (distinctEmails.length === 1) { - attributableTrailer = matchingTrailers[0]; - } else { - return { - resolvedEmail: null, - dcoVerified: false, - reason: `Commit ${sha} has conflicting Signed-off-by trailers for author '${gitAuthorName}'` - }; - } - } + // Filter trailers to those deterministically attributable to the author + const attributable = trailers.filter(t => isTrailerAttributableToAuthor(t, gitAuthor)); + + if (attributable.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} by @${prAuthor} has no Signed-off-by trailer attributable to author` + }; + } + + // Check if multiple attributable trailers share the same email + const distinctCommitEmails = [...new Set(attributable.map(t => t.email))]; + if (distinctCommitEmails.length > 1) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `Commit ${sha} by @${prAuthor} has conflicting Signed-off-by trailers` + }; } - commitEmails.push(attributableTrailer.email); + commitEmails.push(distinctCommitEmails[0]); } // Verify email consistency across all PR-author commits @@ -192,7 +193,7 @@ function resolveIdentity(prAuthor, commits) { return { resolvedEmail: null, dcoVerified: false, - reason: `PR contains conflicting Signed-off-by emails across commits (${distinctEmails.map(maskEmail).join(', ')})` + reason: `PR contains conflicting Signed-off-by emails across author commits (${distinctEmails.map(maskEmail).join(', ')})` }; } @@ -208,5 +209,6 @@ module.exports = { isValidEmail, maskEmail, extractDcoTrailers, + isTrailerAttributableToAuthor, resolveIdentity }; diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index fda5e72..d00285c 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -1,6 +1,12 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { resolveIdentity, extractDcoTrailers, maskEmail, isValidEmail } = require('./identity-resolver'); +const { + resolveIdentity, + extractDcoTrailers, + maskEmail, + isValidEmail, + isTrailerAttributableToAuthor +} = require('./identity-resolver'); test('isValidEmail correctly enforces RFC-style structure', () => { // Rejections @@ -35,6 +41,44 @@ test('extractDcoTrailers extracts and validates standard trailers', () => { assert.equal(trailers[0].email, 'lee@layer5.io'); }); +test('isTrailerAttributableToAuthor handles direct matches and noreply requirements', () => { + // Direct email match + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice@example.com' }, + { name: 'Alice Smith', email: 'alice@example.com' } + ), + true + ); + + // Noreply with matching name + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice.personal@example.com' }, + { name: 'Alice Smith', email: '12345+alicesmith@users.noreply.github.com' } + ), + true + ); + + // Noreply with mismatched name (must fail closed; arbitrary trailers not accepted) + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Bob Jones', email: 'bob@example.com' }, + { name: 'Alice Smith', email: '12345+alicesmith@users.noreply.github.com' } + ), + false + ); + + // Non-noreply with mismatched email and name + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Bob Jones', email: 'bob@example.com' }, + { name: 'Alice Smith', email: 'alice@example.com' } + ), + false + ); +}); + test('resolveIdentity: normal author + matching sign-off', () => { const commits = [ { @@ -52,6 +96,43 @@ test('resolveIdentity: normal author + matching sign-off', () => { assert.equal(result.resolvedEmail, 'lee@layer5.io'); }); +test('resolveIdentity: GitHub noreply commit author with matching trailer name', () => { + const commits = [ + { + sha: 'noreply12345678', + author: { login: 'octocat' }, + commit: { + author: { name: 'Mona Lisa Octocat', email: '12345+octocat@users.noreply.github.com' }, + message: 'docs: web update\n\nSigned-off-by: Mona Lisa Octocat ' + } + } + ]; + + const result = resolveIdentity('octocat', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'mona@example.com'); +}); + +test('resolveIdentity: GitHub noreply commit author with mismatched trailer name fails closed', () => { + const commits = [ + { + sha: 'noreplymismatch1', + author: { login: 'octocat' }, + commit: { + author: { name: 'Mona Lisa Octocat', email: '12345+octocat@users.noreply.github.com' }, + message: 'docs: update\n\nSigned-off-by: Impostor User ' + } + } + ]; + + const result = resolveIdentity('octocat', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('has no Signed-off-by trailer attributable to author')); + // Ensure no plaintext email leaked in reason + assert.equal(result.reason.includes('impostor@example.com'), false); +}); + test('resolveIdentity: maintainer sign-off + contributor sign-off on same commit', () => { const commits = [ { @@ -95,14 +176,15 @@ test('resolveIdentity: author signed commit plus non-author / maintainer commit assert.ok(result.reason.includes('Verified 1 commit(s) by @contributor1')); }); -test('resolveIdentity: mismatched sign-off name and email on author commit (fails closed)', () => { +test('resolveIdentity: mismatched sign-off name and email on author commit fails closed without leaking email', () => { + const plaintextEmail = 'secret.mismatch@corporate.com'; const commits = [ { sha: 'mismatch12345678', author: { login: 'alice' }, commit: { author: { name: 'Alice Smith', email: 'alice@example.com' }, - message: 'fix: bug\n\nSigned-off-by: Bob Jones ' + message: `fix: bug\n\nSigned-off-by: Bob Jones <${plaintextEmail}>` } } ]; @@ -110,7 +192,9 @@ test('resolveIdentity: mismatched sign-off name and email on author commit (fail const result = resolveIdentity('alice', commits); assert.equal(result.dcoVerified, false); assert.equal(result.resolvedEmail, null); - assert.ok(result.reason.includes('does not match git commit author')); + assert.ok(result.reason.includes('has no Signed-off-by trailer attributable to author')); + // Privacy invariant: Plaintext email must NOT appear in reason + assert.equal(result.reason.includes(plaintextEmail), false); }); test('resolveIdentity: commit author mismatch when no commits belong to PR author (fails closed)', () => { @@ -177,22 +261,24 @@ test('resolveIdentity: missing DCO in one of author commits (fails closed)', () assert.ok(result.reason.includes('is missing a valid DCO Signed-off-by trailer')); }); -test('resolveIdentity: multiple author commits with conflicting emails (fails closed)', () => { +test('resolveIdentity: multiple author commits with conflicting emails (fails closed without leaking plaintext)', () => { + const emailA = 'work.address@test.com'; + const emailB = 'personal.address@test.com'; const commits = [ { sha: '1111111111111111', author: { login: 'contributor1' }, commit: { - author: { name: 'Contrib', email: 'work@test.com' }, - message: 'first commit\n\nSigned-off-by: Contrib ' + author: { name: 'Contrib', email: emailA }, + message: `first commit\n\nSigned-off-by: Contrib <${emailA}>` } }, { sha: '2222222222222222', author: { login: 'contributor1' }, commit: { - author: { name: 'Contrib', email: 'personal@test.com' }, - message: 'second commit\n\nSigned-off-by: Contrib ' + author: { name: 'Contrib', email: emailB }, + message: `second commit\n\nSigned-off-by: Contrib <${emailB}>` } } ]; @@ -200,7 +286,10 @@ test('resolveIdentity: multiple author commits with conflicting emails (fails cl const result = resolveIdentity('contributor1', commits); assert.equal(result.dcoVerified, false); assert.equal(result.resolvedEmail, null); - assert.ok(result.reason.includes('conflicting Signed-off-by emails across commits')); + assert.ok(result.reason.includes('conflicting Signed-off-by emails')); + assert.equal(result.reason.includes(emailA), false); + assert.equal(result.reason.includes(emailB), false); + assert.ok(result.reason.includes(maskEmail(emailA))); }); test('resolveIdentity: squashed commit with multiple sign-offs', () => { diff --git a/utils/workflow-integration.test.js b/utils/workflow-integration.test.js index ac87b75..8abd962 100644 --- a/utils/workflow-integration.test.js +++ b/utils/workflow-integration.test.js @@ -53,6 +53,7 @@ test('Integration: full pipeline with paginated API responses, multi-badge award const prMetadata = { repository: 'layer5io/sistent', prAuthor: 'contributor1', + merged: true, files: filesSlurped, commits: commitsSlurped, labels: labelsSlurped @@ -128,6 +129,7 @@ test('Integration: missing DCO blocks award dispatch in pipeline', () => { const prMetadata = { repository: 'meshery/meshery', prAuthor: 'author1', + merged: true, files: [{ filename: 'server/main.go' }], commits: [ { @@ -169,3 +171,97 @@ test('Integration: missing DCO blocks award dispatch in pipeline', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + +test('Integration: unmerged PR safely blocks badge evaluation and award dispatches', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-unmerged-')); + + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'contributor1', + merged: false, // Unmerged PR + files: [{ filename: 'src/components/Button/index.tsx' }], + commits: [ + { + sha: 'unmerged1234', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add button\n\nSigned-off-by: Contributor One ' + } + } + ], + labels: [] + }; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify([]), 'utf-8'); + + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=layer5io/sistent`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + const publicJson = JSON.parse(fs.readFileSync(publicOutPath, 'utf-8')); + assert.equal(publicJson.isMerged, false); + assert.equal(publicJson.pendingAwards.length, 0); + assert.ok(publicJson.summaryMarkdown.includes('not in a merged state')); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('Integration: unauthorized repository fails closed and produces no awards', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-unauthorized-')); + + const prMetadata = { + repository: 'external-org/unknown-repo', + prAuthor: 'contributor1', + merged: true, + files: [{ filename: 'src/index.ts' }], + commits: [ + { + sha: 'unauth1234', + author: { login: 'contributor1' }, + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: code\n\nSigned-off-by: Contributor One ' + } + } + ], + labels: [] + }; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify([]), 'utf-8'); + + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=external-org/unknown-repo`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + const publicJson = JSON.parse(fs.readFileSync(publicOutPath, 'utf-8')); + assert.equal(publicJson.isSupportedRepo, false); + assert.equal(publicJson.pendingAwards.length, 0); + assert.ok(publicJson.summaryMarkdown.includes('not an authorized Track 2 participating repository')); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); From 75011b29619499fb22f792fc4b336b0490b8bd0a Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 18 Sep 2026 20:36:54 +0000 Subject: [PATCH 05/12] fix(resolver): anchor trailer regex and enforce exact noreply domain suffix Signed-off-by: Parth Gartan --- utils/identity-resolver.js | 4 +-- utils/identity-resolver.test.js | 47 ++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index 9dab1df..3074d6e 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -46,7 +46,7 @@ function maskEmail(email) { function extractDcoTrailers(message) { if (!message || typeof message !== 'string') return []; const trailers = []; - const regex = /Signed-off-by:\s*([^<\r\n]+)<([^>\r\n]+)>/gi; + const regex = /^\s*Signed-off-by:\s*([^<\r\n]+)<([^>\r\n]+)>\s*$/gim; let match; while ((match = regex.exec(message)) !== null) { const name = match[1].trim(); @@ -85,7 +85,7 @@ function isTrailerAttributableToAuthor(trailer, gitAuthor) { } // Rule 2: GitHub noreply email requiring strict git author name match - const isNoreply = gitEmail.endsWith('@users.noreply.github.com') || gitEmail.includes('noreply.github.com'); + const isNoreply = gitEmail.endsWith('@users.noreply.github.com') || gitEmail.endsWith('@noreply.github.com'); if (isNoreply && gitName && tName === gitName) { return true; } diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index d00285c..a9d60ef 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -41,6 +41,26 @@ test('extractDcoTrailers extracts and validates standard trailers', () => { assert.equal(trailers[0].email, 'lee@layer5.io'); }); +test('extractDcoTrailers rejects unanchored and prefixed trailer lines', () => { + const invalidMessages = [ + 'Not-Signed-off-by: Lee Calcote ', + 'Prefix Signed-off-by: Lee Calcote ', + 'Signed-off-by: Lee Calcote Suffix text', + 'Some text before Signed-off-by: Lee Calcote and after' + ]; + + for (const msg of invalidMessages) { + const trailers = extractDcoTrailers(msg); + assert.equal(trailers.length, 0, `Expected trailer to be rejected in: ${msg}`); + } + + // Valid with leading/trailing whitespace on its own line + const validWithWhitespace = `feat: update\n\n Signed-off-by: Lee Calcote \n`; + const trailers = extractDcoTrailers(validWithWhitespace); + assert.equal(trailers.length, 1); + assert.equal(trailers[0].email, 'lee@layer5.io'); +}); + test('isTrailerAttributableToAuthor handles direct matches and noreply requirements', () => { // Direct email match assert.equal( @@ -51,7 +71,7 @@ test('isTrailerAttributableToAuthor handles direct matches and noreply requireme true ); - // Noreply with matching name + // Noreply with matching name (@users.noreply.github.com) assert.equal( isTrailerAttributableToAuthor( { name: 'Alice Smith', email: 'alice.personal@example.com' }, @@ -60,6 +80,31 @@ test('isTrailerAttributableToAuthor handles direct matches and noreply requireme true ); + // Noreply with matching name (@noreply.github.com) + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice.personal@example.com' }, + { name: 'Alice Smith', email: 'alicesmith@noreply.github.com' } + ), + true + ); + + // Attacker domains mimicking noreply.github.com must fail closed + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice.personal@example.com' }, + { name: 'Alice Smith', email: '12345+alicesmith@noreply.github.com.attacker.org' } + ), + false + ); + assert.equal( + isTrailerAttributableToAuthor( + { name: 'Alice Smith', email: 'alice.personal@example.com' }, + { name: 'Alice Smith', email: 'alicesmith@users.noreply.github.com.evil.com' } + ), + false + ); + // Noreply with mismatched name (must fail closed; arbitrary trailers not accepted) assert.equal( isTrailerAttributableToAuthor( From d24f830bba7e29d375c087a79bc29cb1dfdd0534 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Sat, 19 Sep 2026 06:33:48 +0000 Subject: [PATCH 06/12] fix(resolver): restrict trailer whitespace to spaces and tabs to prevent multiline match Signed-off-by: Parth Gartan --- utils/identity-resolver.js | 2 +- utils/identity-resolver.test.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index 3074d6e..01d6de1 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -46,7 +46,7 @@ function maskEmail(email) { function extractDcoTrailers(message) { if (!message || typeof message !== 'string') return []; const trailers = []; - const regex = /^\s*Signed-off-by:\s*([^<\r\n]+)<([^>\r\n]+)>\s*$/gim; + const regex = /^[ \t]*Signed-off-by:[ \t]*([^<\r\n]+)<([^>\r\n]+)>[ \t]*$/gim; let match; while ((match = regex.exec(message)) !== null) { const name = match[1].trim(); diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index a9d60ef..60bd07f 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -46,7 +46,10 @@ test('extractDcoTrailers rejects unanchored and prefixed trailer lines', () => { 'Not-Signed-off-by: Lee Calcote ', 'Prefix Signed-off-by: Lee Calcote ', 'Signed-off-by: Lee Calcote Suffix text', - 'Some text before Signed-off-by: Lee Calcote and after' + 'Some text before Signed-off-by: Lee Calcote and after', + 'Signed-off-by:\nLee Calcote ', + 'Signed-off-by:\r\nLee Calcote ', + 'Signed-off-by: Lee Calcote\n' ]; for (const msg of invalidMessages) { From 99ce439537b1a68cc1aaad9a8aaa9d6253ba6708 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Sat, 19 Sep 2026 07:38:01 +0000 Subject: [PATCH 07/12] fix(resolver): restrict noreply detection strictly to users.noreply.github.com Signed-off-by: Parth Gartan --- utils/identity-resolver.js | 2 +- utils/identity-resolver.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index 01d6de1..8c4563c 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -85,7 +85,7 @@ function isTrailerAttributableToAuthor(trailer, gitAuthor) { } // Rule 2: GitHub noreply email requiring strict git author name match - const isNoreply = gitEmail.endsWith('@users.noreply.github.com') || gitEmail.endsWith('@noreply.github.com'); + const isNoreply = gitEmail.endsWith('@users.noreply.github.com'); if (isNoreply && gitName && tName === gitName) { return true; } diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index 60bd07f..a284547 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -83,13 +83,13 @@ test('isTrailerAttributableToAuthor handles direct matches and noreply requireme true ); - // Noreply with matching name (@noreply.github.com) + // Notification address (@noreply.github.com) is not a commit noreply address and must fail closed assert.equal( isTrailerAttributableToAuthor( { name: 'Alice Smith', email: 'alice.personal@example.com' }, { name: 'Alice Smith', email: 'alicesmith@noreply.github.com' } ), - true + false ); // Attacker domains mimicking noreply.github.com must fail closed From 4e6fbbb1dd53ffb844e8ff8966f72eeab79625fc Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Sun, 20 Sep 2026 12:01:41 +0000 Subject: [PATCH 08/12] fix(badges): address merge-gating review findings for Track 2 automation - Distinguish GitHub API HTTP 404 from 403, 429, 5xx, and transport errors without suppressing stderr - Explicitly branch on label query status (200, 404, 403, 429, 5xx) in award workflow - Handle GitHub noreply identities safely: preserve author commit attribution with real sign-off, fail closed if sign-off trailer uses noreply address, and reject generic @noreply.github.com - Strengthen privacy regression tests and add coverage for API error classification and multi-PR evaluation Signed-off-by: Parth Gartan --- .github/workflows/award-project-badge.yml | 65 ++++++++- .github/workflows/test-badge-evaluator.yml | 36 ++++- utils/award-orchestrator.js | 10 ++ utils/award-orchestrator.test.js | 85 ++++++++++- utils/identity-resolver.js | 15 ++ utils/identity-resolver.test.js | 51 ++++++- utils/workflow-integration.test.js | 162 +++++++++++++++++++++ 7 files changed, 407 insertions(+), 17 deletions(-) diff --git a/.github/workflows/award-project-badge.yml b/.github/workflows/award-project-badge.yml index 69d7af2..dcf06da 100644 --- a/.github/workflows/award-project-badge.yml +++ b/.github/workflows/award-project-badge.yml @@ -65,13 +65,37 @@ jobs: set -euo pipefail echo "Fetching Pull Request #${PR_NUMBER} in ${TARGET_REPO}..." - if ! gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2>/dev/null; then - echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}. Exiting without dispatch." - echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" - echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`. Zero badges awarded." >> "$GITHUB_STEP_SUMMARY" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 + GH_ERR_FILE=$(mktemp) + set +e + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2> "${GH_ERR_FILE}" + GH_EXIT_CODE=$? + set -e + + if [ ${GH_EXIT_CODE} -ne 0 ]; then + GH_ERR_MSG=$(cat "${GH_ERR_FILE}") + rm -f "${GH_ERR_FILE}" + + if echo "${GH_ERR_MSG}" | grep -q -E "HTTP 404|Not Found"; then + echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}. Exiting without dispatch." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`. Zero badges awarded." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + elif echo "${GH_ERR_MSG}" | grep -q "HTTP 403"; then + echo "::error::GitHub API access forbidden (HTTP 403) while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + exit 1 + elif echo "${GH_ERR_MSG}" | grep -q -E "HTTP 429|rate limit"; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + exit 1 + elif echo "${GH_ERR_MSG}" | grep -q -E "HTTP 5[0-9]{2}"; then + echo "::error::GitHub API server error while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + exit 1 + else + echo "::error::GitHub API error or network/transport failure while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + exit 1 + fi fi + rm -f "${GH_ERR_FILE}" IS_MERGED=$(jq -r '.merged // false' .pr-info.json) if [ "${IS_MERGED}" != "true" ]; then @@ -217,7 +241,10 @@ jobs: -H "Accept: application/vnd.github.v3+json" \ "https://api.github.com/repos/${TARGET_REPO}/labels/${LABEL_NAME}") - if [ "${LABEL_STATUS}" != "200" ]; then + if [ "${LABEL_STATUS}" = "200" ]; then + echo "Label '${LABEL_NAME}' already exists on ${TARGET_REPO}." + elif [ "${LABEL_STATUS}" = "404" ]; then + echo "Label '${LABEL_NAME}' does not exist on ${TARGET_REPO}. Creating..." CREATE_RESP_FILE=$(mktemp) HTTP_CODE=$(curl -s -w "%{http_code}" -o "${CREATE_RESP_FILE}" \ -X POST \ @@ -237,12 +264,36 @@ jobs: rm -f "${CREATE_RESP_FILE}" exit 1 fi + elif [ "${HTTP_CODE}" = "403" ]; then + echo "::error::GitHub API forbidden (HTTP 403) while creating label '${LABEL_NAME}' on ${TARGET_REPO}: $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" + exit 1 + elif [ "${HTTP_CODE}" = "429" ]; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while creating label '${LABEL_NAME}' on ${TARGET_REPO}: $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" + exit 1 + elif [[ "${HTTP_CODE}" =~ ^5[0-9]{2}$ ]]; then + echo "::error::GitHub API server error (HTTP ${HTTP_CODE}) while creating label '${LABEL_NAME}' on ${TARGET_REPO}: $(cat "${CREATE_RESP_FILE}")" + rm -f "${CREATE_RESP_FILE}" + exit 1 else echo "::error::Failed to create label '${LABEL_NAME}' (HTTP ${HTTP_CODE}): $(cat "${CREATE_RESP_FILE}")" rm -f "${CREATE_RESP_FILE}" exit 1 fi rm -f "${CREATE_RESP_FILE}" + elif [ "${LABEL_STATUS}" = "403" ]; then + echo "::error::GitHub API access forbidden (HTTP 403) while querying label '${LABEL_NAME}' on ${TARGET_REPO}" + exit 1 + elif [ "${LABEL_STATUS}" = "429" ]; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying label '${LABEL_NAME}' on ${TARGET_REPO}" + exit 1 + elif [[ "${LABEL_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "::error::GitHub API server error (HTTP ${LABEL_STATUS}) while querying label '${LABEL_NAME}' on ${TARGET_REPO}" + exit 1 + else + echo "::error::GitHub API error or network failure (HTTP ${LABEL_STATUS}) while querying label '${LABEL_NAME}' on ${TARGET_REPO}" + exit 1 fi # Apply label to PR diff --git a/.github/workflows/test-badge-evaluator.yml b/.github/workflows/test-badge-evaluator.yml index 9c488f3..7abb4e0 100644 --- a/.github/workflows/test-badge-evaluator.yml +++ b/.github/workflows/test-badge-evaluator.yml @@ -50,13 +50,37 @@ jobs: set -euo pipefail echo "Evaluating PR #${PR_NUMBER} from repository: ${TARGET_REPO}..." - if ! gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2>/dev/null; then - echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}." - echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" - echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`." >> "$GITHUB_STEP_SUMMARY" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 + GH_ERR_FILE=$(mktemp) + set +e + gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2> "${GH_ERR_FILE}" + GH_EXIT_CODE=$? + set -e + + if [ ${GH_EXIT_CODE} -ne 0 ]; then + GH_ERR_MSG=$(cat "${GH_ERR_FILE}") + rm -f "${GH_ERR_FILE}" + + if echo "${GH_ERR_MSG}" | grep -q -E "HTTP 404|Not Found"; then + echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + elif echo "${GH_ERR_MSG}" | grep -q "HTTP 403"; then + echo "::error::GitHub API access forbidden (HTTP 403) while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + exit 1 + elif echo "${GH_ERR_MSG}" | grep -q -E "HTTP 429|rate limit"; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + exit 1 + elif echo "${GH_ERR_MSG}" | grep -q -E "HTTP 5[0-9]{2}"; then + echo "::error::GitHub API server error while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + exit 1 + else + echo "::error::GitHub API error or network/transport failure while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + exit 1 + fi fi + rm -f "${GH_ERR_FILE}" IS_MERGED=$(jq -r '.merged // false' .pr-info.json) if [ "${IS_MERGED}" != "true" ]; then diff --git a/utils/award-orchestrator.js b/utils/award-orchestrator.js index e5aa666..fa5ecfb 100644 --- a/utils/award-orchestrator.js +++ b/utils/award-orchestrator.js @@ -169,6 +169,8 @@ function buildSummaryMarkdown({ if (maskedEmail) { lines.push(`- **Recipient Identity**: \`${maskedEmail}\` (${dcoVerified ? '✅ DCO Verified' : '⚠️ DCO Unverified'})`); + } else if (dcoVerified) { + lines.push(`- **Recipient Identity**: ⚠️ Unresolved award recipient (✅ DCO Verified)`); } else { lines.push(`- **Recipient Identity**: ⚠️ Unresolved email`); } @@ -194,6 +196,8 @@ function buildSummaryMarkdown({ status = '✅ **Already Awarded**'; } else if (!dcoVerified) { status = '⚠️ **DCO Blocked**'; + } else if (!maskedEmail) { + status = '⚠️ **Recipient Unresolvable**'; } lines.push(`| **${badge.name}** | \`${badge.slug}\` | ${status} | ${trackingLabel} | ${badge.reason} |`); @@ -201,6 +205,12 @@ function buildSummaryMarkdown({ lines.push(''); + if (dcoVerified && !maskedEmail && allEligibleBadges.length > 0 && alreadyAwardedBadges.length === 0) { + lines.push(`> [!WARNING]`); + lines.push(`> DCO Signed-off-by trailer is verified, but recipient identity cannot be mapped to a Layer5 award recipient. Zero awards dispatched.`); + lines.push(''); + } + if (pendingAwards.length > 0) { lines.push(`### Planned Dispatches (${pendingAwards.length})`); lines.push(''); diff --git a/utils/award-orchestrator.test.js b/utils/award-orchestrator.test.js index f1efce4..2c134d6 100644 --- a/utils/award-orchestrator.test.js +++ b/utils/award-orchestrator.test.js @@ -192,6 +192,85 @@ test('orchestrateAwards blocks awards when DCO is unverified', () => { assert.ok(result.summaryMarkdown.includes('DCO Blocked')); }); +test('orchestrateAwards safely handles noreply sign-off: DCO valid but recipient unresolvable (zero dispatches)', () => { + const prMetadata = { + repository: 'meshery/meshery', + prAuthor: 'noreplydev', + changedFiles: ['server/main.go'], + labels: [], + commits: [ + { + author: { login: 'noreplydev' }, + commit: { + author: { name: 'Noreply Dev', email: '99999+noreplydev@users.noreply.github.com' }, + message: 'fix: update server\n\nSigned-off-by: Noreply Dev <99999+noreplydev@users.noreply.github.com>' + } + } + ] + }; + + const result = orchestrateAwards({ prMetadata }); + + assert.equal(result.dcoVerified, true); + assert.equal(result.recipientEmail, null); + assert.equal(result.allEligibleBadges.length, 1); + assert.equal(result.pendingAwards.length, 0, 'Cannot dispatch awards when recipient email is unresolvable'); + assert.ok(result.summaryMarkdown.includes('Recipient Unresolvable')); + assert.ok(result.summaryMarkdown.includes('Zero awards dispatched')); +}); + +test('orchestrateAwards handles multiple qualifying PRs: PR-level replay protection vs independent PR evaluation', () => { + // Contributor merges PR #100 touching meshery + const pr100 = { + repository: 'meshery/meshery', + prAuthor: 'devX', + changedFiles: ['server/main.go'], + commits: [ + { + author: { login: 'devX' }, + commit: { + author: { name: 'Dev X', email: 'devx@example.com' }, + message: 'feat: add server handler\n\nSigned-off-by: Dev X ' + } + } + ] + }; + + // Initial evaluation of PR #100 -> produces pending award + const initialRunPR100 = orchestrateAwards({ prMetadata: pr100, existingLabels: [] }); + assert.equal(initialRunPR100.pendingAwards.length, 1); + assert.equal(initialRunPR100.pendingAwards[0].slug, 'meshery'); + + // Replay of PR #100 after tracking label applied -> PR-level replay protection skips award + const replayRunPR100 = orchestrateAwards({ + prMetadata: pr100, + existingLabels: ['badge-awarded:meshery'] + }); + assert.equal(replayRunPR100.pendingAwards.length, 0, 'PR-level tracking label prevents duplicate award on same PR replay'); + assert.equal(replayRunPR100.alreadyAwardedBadges.length, 1); + + // Subsequent PR #101 by the same contributor touching meshery (no label on PR #101 yet) + const pr101 = { + repository: 'meshery/meshery', + prAuthor: 'devX', + changedFiles: ['mesheryctl/cmd/system.go'], + commits: [ + { + author: { login: 'devX' }, + commit: { + author: { name: 'Dev X', email: 'devx@example.com' }, + message: 'feat: system command\n\nSigned-off-by: Dev X ' + } + } + ] + }; + + // Track 2 evaluates PR #101 independently (relying on downstream Cloud idempotency for contributor-level deduplication) + const runPR101 = orchestrateAwards({ prMetadata: pr101, existingLabels: [] }); + assert.equal(runPR101.pendingAwards.length, 1); + assert.equal(runPR101.pendingAwards[0].slug, 'meshery'); +}); + test('Privacy verification: sanitized report and step summary never leak plaintext email', () => { const plaintextEmail = 'secret.contributor@privatecorp.com'; const prMetadata = { @@ -215,11 +294,13 @@ test('Privacy verification: sanitized report and step summary never leak plainte // Stringified sanitized report check const serialized = JSON.stringify(sanitized); - assert.equal(serialized.includes(plaintextEmail), false, 'Sanitized report must never contain plaintext email'); + const leakedInJson = serialized.includes(plaintextEmail); + assert.equal(leakedInJson, false, 'Sanitized report must never contain plaintext email'); assert.ok(serialized.includes(sanitized.maskedEmail), 'Sanitized report must contain masked email'); // Summary markdown check - assert.equal(result.summaryMarkdown.includes(plaintextEmail), false, 'Summary markdown must never contain plaintext email'); + const leakedInMarkdown = result.summaryMarkdown.includes(plaintextEmail); + assert.equal(leakedInMarkdown, false, 'Summary markdown must never contain plaintext email'); assert.ok(result.summaryMarkdown.includes(sanitized.maskedEmail)); }); diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index 8c4563c..dfcc11a 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -79,6 +79,11 @@ function isTrailerAttributableToAuthor(trailer, gitAuthor) { const gitEmail = (gitAuthor.email || '').trim().toLowerCase(); const gitName = (gitAuthor.name || '').trim().toLowerCase(); + // Generic @noreply.github.com is a notification address, never valid for commit or trailer attribution + if (gitEmail.endsWith('@noreply.github.com') || tEmail.endsWith('@noreply.github.com')) { + return false; + } + // Rule 1: Direct git commit author email match if (gitEmail && tEmail === gitEmail) { return true; @@ -198,6 +203,16 @@ function resolveIdentity(prAuthor, commits) { } const verifiedEmail = distinctEmails[0]; + + // If the verified trailer is a GitHub noreply address, DCO is valid but recipient cannot be mapped to a Layer5 user + if (verifiedEmail.endsWith('@users.noreply.github.com')) { + return { + resolvedEmail: null, + dcoVerified: true, + reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but recipient uses a GitHub noreply address (@users.noreply.github.com) which cannot be mapped to a Layer5 award recipient` + }; + } + return { resolvedEmail: verifiedEmail, dcoVerified: true, diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index a284547..540a48a 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -161,14 +161,60 @@ test('resolveIdentity: GitHub noreply commit author with matching trailer name', assert.equal(result.resolvedEmail, 'mona@example.com'); }); +test('resolveIdentity: GitHub noreply commit author who signs off with noreply address (CASE B)', () => { + const sensitiveUsername = '12345+octocat'; + const noreplyEmail = `${sensitiveUsername}@users.noreply.github.com`; + const commits = [ + { + sha: 'noreplysame1234', + author: { login: 'octocat' }, + commit: { + author: { name: 'Mona Lisa Octocat', email: noreplyEmail }, + message: `docs: web update\n\nSigned-off-by: Mona Lisa Octocat <${noreplyEmail}>` + } + } + ]; + + const result = resolveIdentity('octocat', commits); + // DCO is valid according to git rules, but recipient is unresolvable for Layer5 awards + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, null, 'Must not use noreply email as award recipient'); + assert.ok(result.reason.includes('cannot be mapped to a Layer5 award recipient')); + // Privacy invariant: Reason must not contain contributor username or sensitive prefix + const leakedUsername = result.reason.includes(sensitiveUsername); + assert.equal(leakedUsername, false, 'Reason must not leak sensitive username prefix'); +}); + +test('resolveIdentity: trailer using generic @noreply.github.com fails closed (CASE C)', () => { + const genericNoreply = 'mona@noreply.github.com'; + const commits = [ + { + sha: 'genericnoreply1', + author: { login: 'octocat' }, + commit: { + author: { name: 'Mona Lisa Octocat', email: genericNoreply }, + message: `docs: web update\n\nSigned-off-by: Mona Lisa Octocat <${genericNoreply}>` + } + } + ]; + + const result = resolveIdentity('octocat', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('has no Signed-off-by trailer attributable to author')); + const leakedEmail = result.reason.includes(genericNoreply); + assert.equal(leakedEmail, false, 'Reason must not leak email'); +}); + test('resolveIdentity: GitHub noreply commit author with mismatched trailer name fails closed', () => { + const fakeEmail = 'impostor@example.com'; const commits = [ { sha: 'noreplymismatch1', author: { login: 'octocat' }, commit: { author: { name: 'Mona Lisa Octocat', email: '12345+octocat@users.noreply.github.com' }, - message: 'docs: update\n\nSigned-off-by: Impostor User ' + message: `docs: update\n\nSigned-off-by: Impostor User <${fakeEmail}>` } } ]; @@ -178,7 +224,8 @@ test('resolveIdentity: GitHub noreply commit author with mismatched trailer name assert.equal(result.resolvedEmail, null); assert.ok(result.reason.includes('has no Signed-off-by trailer attributable to author')); // Ensure no plaintext email leaked in reason - assert.equal(result.reason.includes('impostor@example.com'), false); + const leaked = result.reason.includes(fakeEmail); + assert.equal(leaked, false, 'Reason must not leak trailer email'); }); test('resolveIdentity: maintainer sign-off + contributor sign-off on same commit', () => { diff --git a/utils/workflow-integration.test.js b/utils/workflow-integration.test.js index 8abd962..6b83758 100644 --- a/utils/workflow-integration.test.js +++ b/utils/workflow-integration.test.js @@ -265,3 +265,165 @@ test('Integration: unauthorized repository fails closed and produces no awards', fs.rmSync(tmpDir, { recursive: true, force: true }); }); + +test('Integration: GitHub noreply identity safely suppresses awards in pipeline while maintaining DCO verification', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workflow-noreply-')); + + const sensitiveHandle = '12345+noreplyuser'; + const noreplyEmail = `${sensitiveHandle}@users.noreply.github.com`; + + const prMetadata = { + repository: 'layer5io/sistent', + prAuthor: 'noreplyuser', + merged: true, + files: [{ filename: 'src/components/Button/index.tsx' }], + commits: [ + { + sha: 'noreplycommit1', + author: { login: 'noreplyuser' }, + commit: { + author: { name: 'Noreply User', email: noreplyEmail }, + message: `feat: button\n\nSigned-off-by: Noreply User <${noreplyEmail}>` + } + } + ], + labels: [] + }; + + const metadataPath = path.join(tmpDir, 'pr-metadata.json'); + const labelsPath = path.join(tmpDir, 'existing-labels.json'); + const publicOutPath = path.join(tmpDir, 'evaluation-result.json'); + const dispatchOutPath = path.join(tmpDir, 'dispatch-context.json'); + + fs.writeFileSync(metadataPath, JSON.stringify(prMetadata), 'utf-8'); + fs.writeFileSync(labelsPath, JSON.stringify([]), 'utf-8'); + + const scriptPath = path.resolve(__dirname, 'award-orchestrator.js'); + execFileSync(process.execPath, [ + scriptPath, + `--metadata=${metadataPath}`, + `--existing-labels=${labelsPath}`, + `--repo=layer5io/sistent`, + `--out=${publicOutPath}`, + `--dispatch-out=${dispatchOutPath}` + ]); + + const publicContent = fs.readFileSync(publicOutPath, 'utf-8'); + const publicJson = JSON.parse(publicContent); + + // DCO is verified, but recipient cannot be mapped -> 0 pending awards + assert.equal(publicJson.dcoVerified, true); + assert.equal(publicJson.pendingAwards.length, 0); + assert.ok(publicJson.summaryMarkdown.includes('Recipient Unresolvable')); + + // Privacy invariant: public output does not contain the sensitive handle prefix + const leakedHandle = publicContent.includes(sensitiveHandle); + assert.equal(leakedHandle, false, 'Public output must not contain sensitive handle'); + + // Dispatch context must have null recipientEmail and empty pendingAwards + const dispatchJson = JSON.parse(fs.readFileSync(dispatchOutPath, 'utf-8')); + assert.equal(dispatchJson.recipientEmail, null); + assert.equal(dispatchJson.pendingAwards.length, 0); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('Workflow Shell Logic: GitHub API error classification distinguishes 404 (skip) from 403, 429, 5xx, and transport errors (fail)', () => { + const evaluateApiError = (errMsg) => { + const bashScript = ` + GH_ERR_MSG=$1 + if echo "\${GH_ERR_MSG}" | grep -q -E "HTTP 404|Not Found"; then + echo "SKIP_404" + exit 0 + elif echo "\${GH_ERR_MSG}" | grep -q "HTTP 403"; then + echo "FAIL_403" + exit 1 + elif echo "\${GH_ERR_MSG}" | grep -q -E "HTTP 429|rate limit"; then + echo "FAIL_429" + exit 1 + elif echo "\${GH_ERR_MSG}" | grep -q -E "HTTP 5[0-9]{2}"; then + echo "FAIL_5XX" + exit 1 + else + echo "FAIL_TRANSPORT" + exit 1 + fi + `; + try { + const out = execFileSync('bash', ['-c', bashScript, 'test-sh', errMsg], { encoding: 'utf-8' }); + return { status: 0, output: out.trim() }; + } catch (err) { + return { status: err.status, output: (err.stdout || '').trim() }; + } + }; + + // HTTP 404 -> skip gracefully + const res404 = evaluateApiError('gh: Not Found (HTTP 404)'); + assert.equal(res404.status, 0); + assert.equal(res404.output, 'SKIP_404'); + + // HTTP 403 -> fail + const res403 = evaluateApiError('gh: Forbidden (HTTP 403)'); + assert.equal(res403.status, 1); + assert.equal(res403.output, 'FAIL_403'); + + // HTTP 429 -> fail + const res429 = evaluateApiError('gh: API rate limit exceeded for user (HTTP 429)'); + assert.equal(res429.status, 1); + assert.equal(res429.output, 'FAIL_429'); + + // HTTP 500 / 502 / 503 -> fail + const res500 = evaluateApiError('gh: Server Error (HTTP 500)'); + assert.equal(res500.status, 1); + assert.equal(res500.output, 'FAIL_5XX'); + + // Network / Transport error -> fail + const resTransport = evaluateApiError('curl: (7) Failed to connect to api.github.com port 443: Connection refused'); + assert.equal(resTransport.status, 1); + assert.equal(resTransport.output, 'FAIL_TRANSPORT'); +}); + +test('Workflow Shell Logic: Label query status branching explicitly handles 200, 404, 403, 429, and 5xx', () => { + const evaluateLabelStatus = (statusCode) => { + const bashScript = ` + LABEL_STATUS=$1 + if [ "\${LABEL_STATUS}" = "200" ]; then + echo "EXISTS" + exit 0 + elif [ "\${LABEL_STATUS}" = "404" ]; then + echo "CREATE_LABEL" + exit 0 + elif [ "\${LABEL_STATUS}" = "403" ]; then + echo "FAIL_403" + exit 1 + elif [ "\${LABEL_STATUS}" = "429" ]; then + echo "FAIL_429" + exit 1 + elif [[ "\${LABEL_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "FAIL_5XX" + exit 1 + else + echo "FAIL_UNEXPECTED" + exit 1 + fi + `; + try { + const out = execFileSync('bash', ['-c', bashScript, 'test-sh', statusCode], { encoding: 'utf-8' }); + return { status: 0, output: out.trim() }; + } catch (err) { + return { status: err.status, output: (err.stdout || '').trim() }; + } + }; + + assert.equal(evaluateLabelStatus('200').output, 'EXISTS'); + assert.equal(evaluateLabelStatus('404').output, 'CREATE_LABEL'); + assert.equal(evaluateLabelStatus('403').output, 'FAIL_403'); + assert.equal(evaluateLabelStatus('403').status, 1); + assert.equal(evaluateLabelStatus('429').output, 'FAIL_429'); + assert.equal(evaluateLabelStatus('429').status, 1); + assert.equal(evaluateLabelStatus('500').output, 'FAIL_5XX'); + assert.equal(evaluateLabelStatus('500').status, 1); + assert.equal(evaluateLabelStatus('000').output, 'FAIL_UNEXPECTED'); + assert.equal(evaluateLabelStatus('000').status, 1); +}); + From c676fcf0971e0f3e5ae97aaccc9739e245593445 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Sun, 20 Sep 2026 12:35:05 +0000 Subject: [PATCH 09/12] fix(workflow): parse numeric HTTP status code to branch on API response - Use `gh api --include` to capture the HTTP status line and extract numeric status - Branch strictly on numeric status code (200, 404, 403, 429, 5xx) rather than message text - Avoid falsely classifying 403 Forbidden responses containing 'Not Found' as 404 - Update workflow integration test to verify numeric HTTP status classification Signed-off-by: Parth Gartan --- .github/workflows/award-project-badge.yml | 61 +++++++++++-------- .github/workflows/test-badge-evaluator.yml | 61 +++++++++++-------- utils/workflow-integration.test.js | 69 +++++++++++++++------- 3 files changed, 123 insertions(+), 68 deletions(-) diff --git a/.github/workflows/award-project-badge.yml b/.github/workflows/award-project-badge.yml index dcf06da..84b0c60 100644 --- a/.github/workflows/award-project-badge.yml +++ b/.github/workflows/award-project-badge.yml @@ -65,37 +65,50 @@ jobs: set -euo pipefail echo "Fetching Pull Request #${PR_NUMBER} in ${TARGET_REPO}..." + GH_RESP_FILE=$(mktemp) GH_ERR_FILE=$(mktemp) set +e - gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2> "${GH_ERR_FILE}" + gh api --include "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > "${GH_RESP_FILE}" 2> "${GH_ERR_FILE}" GH_EXIT_CODE=$? set -e - if [ ${GH_EXIT_CODE} -ne 0 ]; then + HTTP_STATUS="" + if [ -s "${GH_RESP_FILE}" ]; then + HTTP_STATUS=$(head -n 1 "${GH_RESP_FILE}" | awk '{print $2}') + fi + + if [ "${HTTP_STATUS}" = "200" ]; then + sed -e '1,/^\r\{0,1\}$/d' "${GH_RESP_FILE}" > .pr-info.json + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + elif [ "${HTTP_STATUS}" = "404" ]; then + echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO} (HTTP 404). Exiting without dispatch." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\` (HTTP 404). Zero badges awarded." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 0 + elif [ "${HTTP_STATUS}" = "403" ]; then + echo "::error::GitHub API access forbidden (HTTP 403) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [ "${HTTP_STATUS}" = "429" ]; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [[ "${HTTP_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "::error::GitHub API server error (HTTP ${HTTP_STATUS}) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [ -n "${HTTP_STATUS}" ]; then + echo "::error::Unexpected GitHub API HTTP status (${HTTP_STATUS}) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + else GH_ERR_MSG=$(cat "${GH_ERR_FILE}") - rm -f "${GH_ERR_FILE}" - - if echo "${GH_ERR_MSG}" | grep -q -E "HTTP 404|Not Found"; then - echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}. Exiting without dispatch." - echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" - echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`. Zero badges awarded." >> "$GITHUB_STEP_SUMMARY" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - elif echo "${GH_ERR_MSG}" | grep -q "HTTP 403"; then - echo "::error::GitHub API access forbidden (HTTP 403) while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" - exit 1 - elif echo "${GH_ERR_MSG}" | grep -q -E "HTTP 429|rate limit"; then - echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" - exit 1 - elif echo "${GH_ERR_MSG}" | grep -q -E "HTTP 5[0-9]{2}"; then - echo "::error::GitHub API server error while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" - exit 1 - else - echo "::error::GitHub API error or network/transport failure while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" - exit 1 - fi + echo "::error::GitHub API network/transport error while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 fi - rm -f "${GH_ERR_FILE}" IS_MERGED=$(jq -r '.merged // false' .pr-info.json) if [ "${IS_MERGED}" != "true" ]; then diff --git a/.github/workflows/test-badge-evaluator.yml b/.github/workflows/test-badge-evaluator.yml index 7abb4e0..fe3ec57 100644 --- a/.github/workflows/test-badge-evaluator.yml +++ b/.github/workflows/test-badge-evaluator.yml @@ -50,37 +50,50 @@ jobs: set -euo pipefail echo "Evaluating PR #${PR_NUMBER} from repository: ${TARGET_REPO}..." + GH_RESP_FILE=$(mktemp) GH_ERR_FILE=$(mktemp) set +e - gh api "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > .pr-info.json 2> "${GH_ERR_FILE}" + gh api --include "repos/${TARGET_REPO}/pulls/${PR_NUMBER}" > "${GH_RESP_FILE}" 2> "${GH_ERR_FILE}" GH_EXIT_CODE=$? set -e - if [ ${GH_EXIT_CODE} -ne 0 ]; then - GH_ERR_MSG=$(cat "${GH_ERR_FILE}") - rm -f "${GH_ERR_FILE}" + HTTP_STATUS="" + if [ -s "${GH_RESP_FILE}" ]; then + HTTP_STATUS=$(head -n 1 "${GH_RESP_FILE}" | awk '{print $2}') + fi - if echo "${GH_ERR_MSG}" | grep -q -E "HTTP 404|Not Found"; then - echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO}." - echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" - echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\`." >> "$GITHUB_STEP_SUMMARY" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - elif echo "${GH_ERR_MSG}" | grep -q "HTTP 403"; then - echo "::error::GitHub API access forbidden (HTTP 403) while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" - exit 1 - elif echo "${GH_ERR_MSG}" | grep -q -E "HTTP 429|rate limit"; then - echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" - exit 1 - elif echo "${GH_ERR_MSG}" | grep -q -E "HTTP 5[0-9]{2}"; then - echo "::error::GitHub API server error while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" - exit 1 - else - echo "::error::GitHub API error or network/transport failure while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" - exit 1 - fi + if [ "${HTTP_STATUS}" = "200" ]; then + sed -e '1,/^\r\{0,1\}$/d' "${GH_RESP_FILE}" > .pr-info.json + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + elif [ "${HTTP_STATUS}" = "404" ]; then + echo "::warning::Pull Request #${PR_NUMBER} not found in ${TARGET_REPO} (HTTP 404)." + echo "## ⚠️ Badge Evaluation Skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Pull Request #${PR_NUMBER} was not found in \`${TARGET_REPO}\` (HTTP 404)." >> "$GITHUB_STEP_SUMMARY" + echo "skip=true" >> "$GITHUB_OUTPUT" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 0 + elif [ "${HTTP_STATUS}" = "403" ]; then + echo "::error::GitHub API access forbidden (HTTP 403) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [ "${HTTP_STATUS}" = "429" ]; then + echo "::error::GitHub API rate limit exceeded (HTTP 429) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [[ "${HTTP_STATUS}" =~ ^5[0-9]{2}$ ]]; then + echo "::error::GitHub API server error (HTTP ${HTTP_STATUS}) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + elif [ -n "${HTTP_STATUS}" ]; then + echo "::error::Unexpected GitHub API HTTP status (${HTTP_STATUS}) while querying PR #${PR_NUMBER} in ${TARGET_REPO}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 + else + GH_ERR_MSG=$(cat "${GH_ERR_FILE}") + echo "::error::GitHub API network/transport error while querying PR #${PR_NUMBER} in ${TARGET_REPO}: ${GH_ERR_MSG}" + rm -f "${GH_RESP_FILE}" "${GH_ERR_FILE}" + exit 1 fi - rm -f "${GH_ERR_FILE}" IS_MERGED=$(jq -r '.merged // false' .pr-info.json) if [ "${IS_MERGED}" != "true" ]; then diff --git a/utils/workflow-integration.test.js b/utils/workflow-integration.test.js index 6b83758..7e9cd82 100644 --- a/utils/workflow-integration.test.js +++ b/utils/workflow-integration.test.js @@ -328,57 +328,86 @@ test('Integration: GitHub noreply identity safely suppresses awards in pipeline fs.rmSync(tmpDir, { recursive: true, force: true }); }); -test('Workflow Shell Logic: GitHub API error classification distinguishes 404 (skip) from 403, 429, 5xx, and transport errors (fail)', () => { - const evaluateApiError = (errMsg) => { +test('Workflow Shell Logic: GitHub API error classification determines outcome strictly by numeric HTTP status, not response message', () => { + const evaluateApiHttpResponse = (rawHeaderAndBody, simulateTransportFailure = false) => { + const tmpResp = path.join(os.tmpdir(), `test-resp-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + const tmpErr = path.join(os.tmpdir(), `test-err-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + + if (!simulateTransportFailure) { + fs.writeFileSync(tmpResp, rawHeaderAndBody, 'utf-8'); + fs.writeFileSync(tmpErr, '', 'utf-8'); + } else { + fs.writeFileSync(tmpResp, '', 'utf-8'); + fs.writeFileSync(tmpErr, rawHeaderAndBody, 'utf-8'); + } + const bashScript = ` - GH_ERR_MSG=$1 - if echo "\${GH_ERR_MSG}" | grep -q -E "HTTP 404|Not Found"; then + GH_RESP_FILE="$1" + GH_ERR_FILE="$2" + + HTTP_STATUS="" + if [ -s "\${GH_RESP_FILE}" ]; then + HTTP_STATUS=$(head -n 1 "\${GH_RESP_FILE}" | awk '{print $2}') + fi + + if [ "\${HTTP_STATUS}" = "200" ]; then + echo "SUCCESS_200" + exit 0 + elif [ "\${HTTP_STATUS}" = "404" ]; then echo "SKIP_404" exit 0 - elif echo "\${GH_ERR_MSG}" | grep -q "HTTP 403"; then + elif [ "\${HTTP_STATUS}" = "403" ]; then echo "FAIL_403" exit 1 - elif echo "\${GH_ERR_MSG}" | grep -q -E "HTTP 429|rate limit"; then + elif [ "\${HTTP_STATUS}" = "429" ]; then echo "FAIL_429" exit 1 - elif echo "\${GH_ERR_MSG}" | grep -q -E "HTTP 5[0-9]{2}"; then + elif [[ "\${HTTP_STATUS}" =~ ^5[0-9]{2}$ ]]; then echo "FAIL_5XX" exit 1 + elif [ -n "\${HTTP_STATUS}" ]; then + echo "FAIL_UNEXPECTED" + exit 1 else echo "FAIL_TRANSPORT" exit 1 fi `; + try { - const out = execFileSync('bash', ['-c', bashScript, 'test-sh', errMsg], { encoding: 'utf-8' }); + const out = execFileSync('bash', ['-c', bashScript, 'test-sh', tmpResp, tmpErr], { encoding: 'utf-8' }); + fs.rmSync(tmpResp, { force: true }); + fs.rmSync(tmpErr, { force: true }); return { status: 0, output: out.trim() }; } catch (err) { + fs.rmSync(tmpResp, { force: true }); + fs.rmSync(tmpErr, { force: true }); return { status: err.status, output: (err.stdout || '').trim() }; } }; - // HTTP 404 -> skip gracefully - const res404 = evaluateApiError('gh: Not Found (HTTP 404)'); + // 1. HTTP 404 with message "Not Found" -> skip gracefully + const res404 = evaluateApiHttpResponse('HTTP/2.0 404 Not Found\r\nContent-Type: application/json\r\n\r\n{"message":"Not Found"}'); assert.equal(res404.status, 0); assert.equal(res404.output, 'SKIP_404'); - // HTTP 403 -> fail - const res403 = evaluateApiError('gh: Forbidden (HTTP 403)'); - assert.equal(res403.status, 1); - assert.equal(res403.output, 'FAIL_403'); + // 2. HTTP 403 with message "Not Found" -> MUST fail (proves classification does NOT rely on "Not Found") + const res403NotFound = evaluateApiHttpResponse('HTTP/2.0 403 Forbidden\r\nContent-Type: application/json\r\n\r\n{"message":"Not Found"}'); + assert.equal(res403NotFound.status, 1, 'HTTP 403 containing message "Not Found" must fail, not skip'); + assert.equal(res403NotFound.output, 'FAIL_403'); - // HTTP 429 -> fail - const res429 = evaluateApiError('gh: API rate limit exceeded for user (HTTP 429)'); + // 3. HTTP 429 -> MUST fail + const res429 = evaluateApiHttpResponse('HTTP/2.0 429 Too Many Requests\r\nContent-Type: application/json\r\n\r\n{"message":"API rate limit exceeded"}'); assert.equal(res429.status, 1); assert.equal(res429.output, 'FAIL_429'); - // HTTP 500 / 502 / 503 -> fail - const res500 = evaluateApiError('gh: Server Error (HTTP 500)'); + // 4. HTTP 500 -> MUST fail + const res500 = evaluateApiHttpResponse('HTTP/2.0 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{"message":"Internal Server Error"}'); assert.equal(res500.status, 1); assert.equal(res500.output, 'FAIL_5XX'); - // Network / Transport error -> fail - const resTransport = evaluateApiError('curl: (7) Failed to connect to api.github.com port 443: Connection refused'); + // 5. Transport/network failure -> MUST fail + const resTransport = evaluateApiHttpResponse('curl: (7) Failed to connect to api.github.com port 443: Connection refused', true); assert.equal(resTransport.status, 1); assert.equal(resTransport.output, 'FAIL_TRANSPORT'); }); From 354b1ad340a81d41e5be46c6dcf48781d88431b8 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Sun, 20 Sep 2026 13:20:55 +0000 Subject: [PATCH 10/12] fix(resolver): enforce terminal Git trailer block and noreply recipient safety - Require Signed-off-by to be in the terminal Git trailer block, rejecting sign-offs followed by later body text - Never resolve recipient email solely from matching contributor-controlled names when commit author uses @users.noreply.github.com - Preserve exact noreply DCO attribution (dcoVerified: true) while keeping recipient unresolved (resolvedEmail: null) - Add regression tests for terminal trailer block parsing and noreply spoofing prevention Signed-off-by: Parth Gartan --- utils/identity-resolver.js | 78 ++++++++++++++++++++++++++++----- utils/identity-resolver.test.js | 60 +++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index dfcc11a..38e42ef 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -36,25 +36,57 @@ function maskEmail(email) { } /** - * Extracts all valid Signed-off-by trailers from a commit message. + * Extracts all valid Signed-off-by trailers from the terminal Git trailer block of a commit message. * Formats supported: "Signed-off-by: First Last " - * Strictly validates trailer syntax and email format. + * + * Terminal Trailer Block Rules (Git interpret-trailers specification): + * 1. Must be located in the terminal paragraph at the end of the commit message. + * 2. Every line in the terminal block must be a valid trailer line ("Token: Value"). + * 3. No subsequent body text may follow the trailer block. + * 4. Strictly validates trailer syntax and email format. * * @param {string} message Commit message * @returns {Array<{ name: string, email: string }>} */ function extractDcoTrailers(message) { if (!message || typeof message !== 'string') return []; + + // Normalize line breaks and trim trailing whitespace + const normalized = message.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd(); + if (!normalized) return []; + + // Split into paragraphs separated by one or more blank lines + const paragraphs = normalized.split(/\n[ \t]*\n+/); + const terminalParagraph = paragraphs[paragraphs.length - 1].trim(); + if (!terminalParagraph) return []; + + const lines = terminalParagraph.split('\n'); + + // Verify that EVERY line in the terminal paragraph is a valid trailer line + // (e.g. "Signed-off-by: ...", "Co-authored-by: ...", "Fixes: ...", "Token: Value") + const genericTrailerRegex = /^[ \t]*[A-Za-z0-9-_]+:[ \t]*.*$/; + for (const line of lines) { + if (!genericTrailerRegex.test(line)) { + // If there is regular body text in the terminal block, it is not a valid trailer block + return []; + } + } + + // Parse Signed-off-by trailers from the terminal trailer block + const dcoTrailerRegex = /^[ \t]*Signed-off-by:[ \t]*([^<\r\n]+)<([^>\r\n]+)>[ \t]*$/i; const trailers = []; - const regex = /^[ \t]*Signed-off-by:[ \t]*([^<\r\n]+)<([^>\r\n]+)>[ \t]*$/gim; - let match; - while ((match = regex.exec(message)) !== null) { - const name = match[1].trim(); - const rawEmail = match[2].trim(); - if (name && isValidEmail(rawEmail)) { - trailers.push({ name, email: rawEmail.toLowerCase() }); + + for (const line of lines) { + const match = dcoTrailerRegex.exec(line); + if (match) { + const name = match[1].trim(); + const rawEmail = match[2].trim(); + if (name && isValidEmail(rawEmail)) { + trailers.push({ name, email: rawEmail.toLowerCase() }); + } } } + return trailers; } @@ -107,6 +139,12 @@ function isTrailerAttributableToAuthor(trailer, gitAuthor) { * → DCO Signed-off-by trailer deterministically attributable to that commit author * → verified RFC-compliant email * + * Security & Anti-Spoofing Invariant: + * When a commit author uses a GitHub noreply email (@users.noreply.github.com), + * a real recipient email is NEVER resolved solely from matching contributor-controlled names. + * Exact noreply DCO attribution is preserved (dcoVerified: true), but resolvedEmail + * remains null. + * * Privacy Invariant: * The returned reason string NEVER contains plaintext contributor email addresses. * @@ -148,6 +186,7 @@ function resolveIdentity(prAuthor, commits) { } const commitEmails = []; + let hasNoreplyAuthor = false; for (let idx = 0; idx < authorCommits.length; idx++) { const item = authorCommits[idx]; @@ -155,6 +194,10 @@ function resolveIdentity(prAuthor, commits) { // Git commit author metadata const gitAuthor = item.commit && item.commit.author ? item.commit.author : {}; + const gitEmail = (gitAuthor.email || '').trim().toLowerCase(); + if (gitEmail.endsWith('@users.noreply.github.com')) { + hasNoreplyAuthor = true; + } // Extract DCO trailers const message = item.commit ? item.commit.message : (item.message || ''); @@ -204,12 +247,23 @@ function resolveIdentity(prAuthor, commits) { const verifiedEmail = distinctEmails[0]; - // If the verified trailer is a GitHub noreply address, DCO is valid but recipient cannot be mapped to a Layer5 user - if (verifiedEmail.endsWith('@users.noreply.github.com')) { + // If the commit author used a GitHub noreply address (@users.noreply.github.com), + // never resolve a real recipient email solely from contributor-controlled names. + // Exact noreply DCO attribution is preserved (dcoVerified: true), but recipient + // remains unresolved (resolvedEmail: null). + if (hasNoreplyAuthor || verifiedEmail.endsWith('@users.noreply.github.com')) { + if (verifiedEmail.endsWith('@users.noreply.github.com')) { + return { + resolvedEmail: null, + dcoVerified: true, + reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but recipient uses a GitHub noreply address (@users.noreply.github.com) which cannot be mapped to a Layer5 award recipient` + }; + } + return { resolvedEmail: null, dcoVerified: true, - reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but recipient uses a GitHub noreply address (@users.noreply.github.com) which cannot be mapped to a Layer5 award recipient` + reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but commit author uses a GitHub noreply address (@users.noreply.github.com); recipient cannot be resolved from contributor-controlled names` }; } diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index 540a48a..99b0bdc 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -64,6 +64,38 @@ test('extractDcoTrailers rejects unanchored and prefixed trailer lines', () => { assert.equal(trailers[0].email, 'lee@layer5.io'); }); +test('extractDcoTrailers rejects Signed-off-by followed by later body text (terminal trailer block invariant)', () => { + // 1. Later body text in a subsequent paragraph + const msgWithLaterParagraph = `feat(api): update endpoints\n\nSigned-off-by: Lee Calcote \n\nNote: This commit was later amended and should not be credited.`; + assert.equal(extractDcoTrailers(msgWithLaterParagraph).length, 0); + + // 2. Later body text in the same paragraph + const msgWithSameParagraphBody = `feat(api): update endpoints\n\nSigned-off-by: Lee Calcote \nAdditional explanatory body text here.`; + assert.equal(extractDcoTrailers(msgWithSameParagraphBody).length, 0); + + // 3. Body text before Signed-off-by in the same paragraph without blank line + const msgWithPrecedingParagraphBody = `feat(api): update endpoints\n\nSome body text without empty line separation\nSigned-off-by: Lee Calcote `; + assert.equal(extractDcoTrailers(msgWithPrecedingParagraphBody).length, 0); +}); + +test('resolveIdentity fails closed when Signed-off-by is not in terminal trailer block', () => { + const commits = [ + { + sha: 'terminal1234567', + author: { login: 'leecalcote' }, + commit: { + author: { name: 'Lee Calcote', email: 'lee@layer5.io' }, + message: 'fix: update configuration\n\nSigned-off-by: Lee Calcote \n\nLater text explaining the change' + } + } + ]; + + const result = resolveIdentity('leecalcote', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('missing a valid DCO Signed-off-by trailer')); +}); + test('isTrailerAttributableToAuthor handles direct matches and noreply requirements', () => { // Direct email match assert.equal( @@ -144,7 +176,7 @@ test('resolveIdentity: normal author + matching sign-off', () => { assert.equal(result.resolvedEmail, 'lee@layer5.io'); }); -test('resolveIdentity: GitHub noreply commit author with matching trailer name', () => { +test('resolveIdentity: GitHub noreply commit author with matching trailer name preserves DCO but keeps recipient unresolved', () => { const commits = [ { sha: 'noreply12345678', @@ -157,8 +189,30 @@ test('resolveIdentity: GitHub noreply commit author with matching trailer name', ]; const result = resolveIdentity('octocat', commits); - assert.equal(result.dcoVerified, true); - assert.equal(result.resolvedEmail, 'mona@example.com'); + assert.equal(result.dcoVerified, true, 'DCO attribution is preserved for matching trailer name'); + assert.equal(result.resolvedEmail, null, 'Must never resolve recipient email solely from matching contributor-controlled name on noreply author'); + assert.ok(result.reason.includes('cannot be resolved from contributor-controlled names')); +}); + +test('resolveIdentity: spoofing regression - attacker using noreply author cannot claim victim email via matching trailer name', () => { + const victimEmail = 'victim@layer5.io'; + const commits = [ + { + sha: 'spoof12345678', + author: { login: 'attacker' }, + commit: { + // Attacker sets their git author name to victim's name, but author email is attacker's GitHub noreply + author: { name: 'Victim User', email: '99999+attacker@users.noreply.github.com' }, + message: `feat: malicious change\n\nSigned-off-by: Victim User <${victimEmail}>` + } + } + ]; + + const result = resolveIdentity('attacker', commits); + assert.equal(result.resolvedEmail, null, 'Must never resolve spoofed victim email from noreply commit author'); + assert.equal(result.dcoVerified, true, 'DCO trailer name match preserves DCO attribution'); + assert.ok(result.reason.includes('cannot be resolved from contributor-controlled names')); + assert.equal(result.reason.includes(victimEmail), false, 'Reason must not leak victim email'); }); test('resolveIdentity: GitHub noreply commit author who signs off with noreply address (CASE B)', () => { From 11b4317cba10d55c94107d5a9c703520b56346de Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Sun, 20 Sep 2026 15:24:24 +0000 Subject: [PATCH 11/12] fix(resolver): skip merge commits in DCO evaluation and add CI + drift detection - Skip merge commits (parents > 1) before reading git author metadata to prevent false noreply detection and false DCO failures - Add fail-closed guard when all author commits are merge-only - Add regression tests: signed+merge, merge-only, unsigned+merge - Add badge-engine-ci.yml workflow (SHA-pinned, triggers on utils/**, package.json, package-lock.json, .github/workflows/**) - Add allowlist drift-detection test comparing JS SUPPORTED_REPOSITORIES against workflow shell case statements as sets - Add inline comment documenting Layer5 Cloud Slack delivery path - Fix trailing blank line in workflow-integration.test.js Signed-off-by: Parth Gartan --- .github/workflows/award-project-badge.yml | 3 + .github/workflows/badge-engine-ci.yml | 23 +++++++ utils/identity-resolver.js | 19 ++++++ utils/identity-resolver.test.js | 83 +++++++++++++++++++++++ utils/workflow-integration.test.js | 45 ++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 .github/workflows/badge-engine-ci.yml diff --git a/.github/workflows/award-project-badge.yml b/.github/workflows/award-project-badge.yml index 84b0c60..d23419b 100644 --- a/.github/workflows/award-project-badge.yml +++ b/.github/workflows/award-project-badge.yml @@ -225,6 +225,9 @@ jobs: echo "[DRY-RUN] Would post award command for ${BADGE_SLUG} to recipient (${MASKED_EMAIL})" else echo "Dispatching award for ${BADGE_SLUG} to recipient (${MASKED_EMAIL})..." + # The bot posts a message containing "/award-badge " to the + # canonical Slack channel. The Layer5 Cloud Slack integration monitors this + # channel and consumes the message to issue the badge award. PAYLOAD=$(jq -n \ --arg ch "${CANONICAL_SLACK_CHANNEL}" \ --arg txt "/award-badge ${EMAIL} ${BADGE_SLUG}" \ diff --git a/.github/workflows/badge-engine-ci.yml b/.github/workflows/badge-engine-ci.yml new file mode 100644 index 0000000..377d7b0 --- /dev/null +++ b/.github/workflows/badge-engine-ci.yml @@ -0,0 +1,23 @@ +name: Badge Engine Tests + +on: + pull_request: + paths: + - 'utils/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/**' + +permissions: + contents: read + +jobs: + test-badge-engine: + name: Run Badge Engine Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 pinned SHA + + - name: Run badge engine tests + run: npm run test:badge-engine diff --git a/utils/identity-resolver.js b/utils/identity-resolver.js index 38e42ef..c267266 100644 --- a/utils/identity-resolver.js +++ b/utils/identity-resolver.js @@ -192,6 +192,14 @@ function resolveIdentity(prAuthor, commits) { const item = authorCommits[idx]; const sha = (item && item.sha ? item.sha.slice(0, 7) : `commit-${idx + 1}`); + // Skip merge commits (parents > 1): these are GitHub-generated merge commits + // that never carry DCO trailers. Must be skipped before reading git author + // metadata to prevent false noreply detection. + const parents = item.parents || (item.commit && item.commit.parents) || []; + if (Array.isArray(parents) && parents.length > 1) { + continue; + } + // Git commit author metadata const gitAuthor = item.commit && item.commit.author ? item.commit.author : {}; const gitEmail = (gitAuthor.email || '').trim().toLowerCase(); @@ -237,6 +245,17 @@ function resolveIdentity(prAuthor, commits) { // Verify email consistency across all PR-author commits const distinctEmails = [...new Set(commitEmails)]; + + // If all author commits were merge commits (all skipped), there are no + // code commits to evaluate. Fail closed. + if (distinctEmails.length === 0) { + return { + resolvedEmail: null, + dcoVerified: false, + reason: `No non-merge code commits found for @${prAuthor} (all ${authorCommits.length} commit(s) are merge commits)` + }; + } + if (distinctEmails.length > 1) { return { resolvedEmail: null, diff --git a/utils/identity-resolver.test.js b/utils/identity-resolver.test.js index 99b0bdc..d37b981 100644 --- a/utils/identity-resolver.test.js +++ b/utils/identity-resolver.test.js @@ -457,3 +457,86 @@ test('resolveIdentity: squashed commit with multiple sign-offs', () => { assert.equal(result.dcoVerified, true); assert.equal(result.resolvedEmail, 'dev@company.com'); }); + +test('resolveIdentity: signed normal commit + unsigned merge commit => success (merge commit skipped)', () => { + const commits = [ + { + sha: 'code111111111111', + author: { login: 'contributor1' }, + parents: [{ sha: 'parent1' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add feature\n\nSigned-off-by: Contributor One ' + } + }, + { + sha: 'merge222222222222', + author: { login: 'contributor1' }, + parents: [{ sha: 'parentA' }, { sha: 'parentB' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: "Merge branch 'master' into feat/my-feature" + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, true); + assert.equal(result.resolvedEmail, 'contrib@layer5.io'); +}); + +test('resolveIdentity: merge-only commit history => fails closed (no code commits)', () => { + const commits = [ + { + sha: 'merge333333333333', + author: { login: 'contributor1' }, + parents: [{ sha: 'parentA' }, { sha: 'parentB' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: "Merge branch 'master' into feat/my-feature" + } + }, + { + sha: 'merge444444444444', + author: { login: 'contributor1' }, + parents: [{ sha: 'parentC' }, { sha: 'parentD' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: "Merge branch 'develop' into feat/my-feature" + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('No non-merge code commits found')); +}); + +test('resolveIdentity: unsigned normal commit + merge commit => fails closed (merge skip does not rescue unsigned code)', () => { + const commits = [ + { + sha: 'unsigned55555555', + author: { login: 'contributor1' }, + parents: [{ sha: 'parent1' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: 'feat: add feature without DCO' + } + }, + { + sha: 'merge666666666666', + author: { login: 'contributor1' }, + parents: [{ sha: 'parentA' }, { sha: 'parentB' }], + commit: { + author: { name: 'Contributor One', email: 'contrib@layer5.io' }, + message: "Merge branch 'master' into feat/my-feature" + } + } + ]; + + const result = resolveIdentity('contributor1', commits); + assert.equal(result.dcoVerified, false); + assert.equal(result.resolvedEmail, null); + assert.ok(result.reason.includes('missing a valid DCO Signed-off-by trailer')); +}); diff --git a/utils/workflow-integration.test.js b/utils/workflow-integration.test.js index 7e9cd82..06c67b4 100644 --- a/utils/workflow-integration.test.js +++ b/utils/workflow-integration.test.js @@ -456,3 +456,48 @@ test('Workflow Shell Logic: Label query status branching explicitly handles 200, assert.equal(evaluateLabelStatus('000').status, 1); }); +test('Allowlist Drift Detection: workflow shell case statements match SUPPORTED_REPOSITORIES', () => { + const { SUPPORTED_REPOSITORIES } = require('./badge-evaluator'); + + const jsSet = new Set(SUPPORTED_REPOSITORIES.map(r => r.toLowerCase())); + + // Extract repositories from shell case statements in workflow files + const workflowFiles = [ + path.join(__dirname, '..', '.github', 'workflows', 'award-project-badge.yml'), + path.join(__dirname, '..', '.github', 'workflows', 'test-badge-evaluator.yml') + ]; + + for (const workflowPath of workflowFiles) { + const basename = path.basename(workflowPath); + const content = fs.readFileSync(workflowPath, 'utf-8'); + + // Match the case pattern line: "repo1"|"repo2"|...) at the start of a case branch + const caseMatch = content.match(/"([^"]+)"(?:\|"([^"]+)")*\)/g); + assert.ok(caseMatch && caseMatch.length > 0, `No case pattern found in ${basename}`); + + // Take the first case match (the allowlist pattern) + const patternLine = caseMatch[0]; + const shellRepos = new Set( + patternLine + .replace(/\)$/, '') + .split('|') + .map(s => s.replace(/"/g, '').trim().toLowerCase()) + .filter(Boolean) + ); + + // Compare as sets: find missing and extra + const missingFromShell = [...jsSet].filter(r => !shellRepos.has(r)); + const extraInShell = [...shellRepos].filter(r => !jsSet.has(r)); + + assert.deepStrictEqual( + missingFromShell, + [], + `${basename}: repositories in SUPPORTED_REPOSITORIES but missing from shell case: ${missingFromShell.join(', ')}` + ); + assert.deepStrictEqual( + extraInShell, + [], + `${basename}: repositories in shell case but missing from SUPPORTED_REPOSITORIES: ${extraInShell.join(', ')}` + ); + } +}); From 2ea4835d0f851d452308f6a1e8685ecef59d095f Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Wed, 7 Oct 2026 12:42:08 +0000 Subject: [PATCH 12/12] fix(badges): align track 2 rules with automation proposal Signed-off-by: Parth Gartan --- utils/award-orchestrator.test.js | 4 +- utils/badge-evaluator.js | 119 +++++--- utils/badge-evaluator.test.js | 486 +++++++++++++++++++++++-------- utils/badge-rules.json | 125 ++++---- 4 files changed, 508 insertions(+), 226 deletions(-) diff --git a/utils/award-orchestrator.test.js b/utils/award-orchestrator.test.js index 2c134d6..35077c9 100644 --- a/utils/award-orchestrator.test.js +++ b/utils/award-orchestrator.test.js @@ -68,7 +68,7 @@ test('orchestrateAwards produces pending award on qualifying fresh PR', () => { const prMetadata = { repository: 'layer5io/sistent', prAuthor: 'contributor1', - changedFiles: ['src/components/Button/index.tsx'], + changedFiles: ['examples/sample-app/index.tsx'], labels: ['enhancement'], commits: [ { @@ -143,7 +143,7 @@ test('orchestrateAwards filters out already awarded badges (Idempotency)', () => const prMetadata = { repository: 'layer5io/sistent', prAuthor: 'contributor1', - changedFiles: ['src/components/Button/index.tsx'], + changedFiles: ['examples/sample-app/index.tsx'], labels: ['enhancement'], commits: [ { diff --git a/utils/badge-evaluator.js b/utils/badge-evaluator.js index 1d5bc78..8fc5da4 100644 --- a/utils/badge-evaluator.js +++ b/utils/badge-evaluator.js @@ -1,16 +1,38 @@ -const defaultRules = require('./badge-rules.json'); +const defaultRules = require("./badge-rules.json"); /** * Authoritative allowlist of participating Track 2 ecosystem repositories. */ const SUPPORTED_REPOSITORIES = Object.freeze([ - 'layer5io/sistent', - 'meshery/meshery', - 'meshery/meshery-operator', - 'meshery/meshsync', - 'layer5io/docs', - 'meshery/meshery.io', - 'layer5io/layer5' + "layer5io/sistent", + "meshery/meshery", + "meshery/meshery-operator", + "meshery/meshsync", + "layer5io/docs", + "meshery/meshery.io", + "layer5io/layer5" +]); + +/** + * Universal Track 2 exclusions applied consistently to all Track 2 badge rules: + * - test files: tests, test.go, __tests__ + * - lockfiles: package-lock.json, yarn.lock, go.sum (at root or nested) + * - repository governance: .github/**, LICENSE, README.md, CONTRIBUTING*.md + */ +const UNIVERSAL_EXCLUSIONS = Object.freeze([ + "**/*.test.*", + "**/*_test.go", + "**/__tests__/**", + "package-lock.json", + "**/package-lock.json", + "yarn.lock", + "**/yarn.lock", + "go.sum", + "**/go.sum", + ".github/**", + "LICENSE", + "README.md", + "CONTRIBUTING*.md" ]); /** @@ -19,7 +41,7 @@ const SUPPORTED_REPOSITORIES = Object.freeze([ * @returns {boolean} */ function isSupportedRepository(repository) { - if (!repository || typeof repository !== 'string') return false; + if (!repository || typeof repository !== "string") return false; return SUPPORTED_REPOSITORIES.includes(repository.trim().toLowerCase()); } @@ -30,42 +52,42 @@ function isSupportedRepository(repository) { * - `*` : wildcards within path segment / filename * - exact file or path matches * - * @param {string} pattern Glob pattern (e.g. "src/**", "**\/*.test.*") + * @param {string} pattern Glob pattern (e.g. "src/**") * @param {string} filePath Normalized file path (e.g. "src/components/button.tsx") * @returns {boolean} */ function matchGlob(pattern, filePath) { if (!pattern || !filePath) return false; - const normPath = filePath.replace(/\\/g, '/').replace(/^\/+/, ''); - const normPattern = pattern.replace(/\\/g, '/').replace(/^\/+/, ''); + const normPath = filePath.replace(/\\/g, "/").replace(/^\/+/, ""); + const normPattern = pattern.replace(/\\/g, "/").replace(/^\/+/, ""); if (normPattern === normPath) return true; - let regexStr = '^'; + let regexStr = "^"; let i = 0; while (i < normPattern.length) { const c = normPattern[i]; - if (c === '*' && normPattern[i + 1] === '*') { - if (normPattern[i + 2] === '/') { - regexStr += '(?:.*/)?'; + if (c === "*" && normPattern[i + 1] === "*") { + if (normPattern[i + 2] === "/") { + regexStr += "(?:.*/)?"; i += 3; } else { - regexStr += '.*'; + regexStr += ".*"; i += 2; } - } else if (c === '*') { - regexStr += '[^/]*'; + } else if (c === "*") { + regexStr += "[^/]*"; i += 1; - } else if (['.', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']'].includes(c)) { - regexStr += '\\' + c; + } else if (["[", "]", ".", "+", "?", "^", "$", "{", "}", "(", ")", "|"].includes(c)) { + regexStr += "\\" + c; i += 1; } else { regexStr += c; i += 1; } } - regexStr += '$'; + regexStr += "$"; try { return new RegExp(regexStr).test(normPath); @@ -83,9 +105,9 @@ function normalizeLabels(labels) { if (!Array.isArray(labels)) return []; return labels .map(label => { - if (typeof label === 'string') return label.trim().toLowerCase(); - if (label && typeof label.name === 'string') return label.name.trim().toLowerCase(); - return ''; + if (typeof label === "string") return label.trim().toLowerCase(); + if (label && typeof label.name === "string") return label.name.trim().toLowerCase(); + return ""; }) .filter(Boolean); } @@ -99,9 +121,9 @@ function normalizeFiles(files) { if (!Array.isArray(files)) return []; return files .map(file => { - if (typeof file === 'string') return file.trim().replace(/\\/g, '/'); - if (file && typeof file.filename === 'string') return file.filename.trim().replace(/\\/g, '/'); - return ''; + if (typeof file === "string") return file.trim().replace(/\\/g, "/"); + if (file && typeof file.filename === "string") return file.filename.trim().replace(/\\/g, "/"); + return ""; }) .filter(Boolean); } @@ -119,7 +141,7 @@ function normalizeFiles(files) { * @returns {{ eligibleBadges: Array<{ slug: string, name: string, reason: string, ruleId: string }>, isSupportedRepo: boolean }} */ function evaluateBadges(prContext = {}, rules = defaultRules) { - const repository = (prContext.repository || prContext.repo || '').trim().toLowerCase(); + const repository = (prContext.repository || prContext.repo || "").trim().toLowerCase(); const rawLabels = normalizeLabels(prContext.labels); const rawFiles = normalizeFiles(prContext.changedFiles || prContext.files); @@ -140,10 +162,23 @@ function evaluateBadges(prContext = {}, rules = defaultRules) { continue; } - // Check label requirements (if rule specifies requiredAnyLabels) - if (rule.requiredAnyLabels && rule.requiredAnyLabels.length > 0) { - const requiredAny = rule.requiredAnyLabels.map(l => l.toLowerCase()); - const hasMatchingLabel = rawLabels.some(label => requiredAny.includes(label)); + // Determine applicable requiredAnyLabels for this repository + let requiredLabels = null; + if (rule.repoSpecificRequiredAnyLabels) { + for (const [repoKey, labels] of Object.entries(rule.repoSpecificRequiredAnyLabels)) { + if (repoKey.toLowerCase() === repository) { + requiredLabels = labels; + break; + } + } + } + if (requiredLabels === null && Array.isArray(rule.requiredAnyLabels)) { + requiredLabels = rule.requiredAnyLabels; + } + + if (Array.isArray(requiredLabels) && requiredLabels.length > 0) { + const requiredNormalized = requiredLabels.map(l => l.toLowerCase()); + const hasMatchingLabel = rawLabels.some(label => requiredNormalized.includes(label)); if (!hasMatchingLabel) { continue; } @@ -159,7 +194,14 @@ function evaluateBadges(prContext = {}, rules = defaultRules) { } } - const excludePatterns = rule.excludePatterns || []; + let excludePatterns = (rule.excludePatterns || []).concat(UNIVERSAL_EXCLUSIONS); + if (rule.repoSpecificExcludePatterns) { + for (const [repoKey, patterns] of Object.entries(rule.repoSpecificExcludePatterns)) { + if (repoKey.toLowerCase() === repository) { + excludePatterns = excludePatterns.concat(patterns); + } + } + } // Filter changed files: must match at least one include pattern, and NOT match any exclude pattern const matchingFiles = rawFiles.filter(filePath => { @@ -170,12 +212,12 @@ function evaluateBadges(prContext = {}, rules = defaultRules) { }); if (matchingFiles.length > 0) { - const sampleFiles = matchingFiles.slice(0, 3).join(', '); - const moreSuffix = matchingFiles.length > 3 ? ` and ${matchingFiles.length - 3} more` : ''; + const sampleFiles = matchingFiles.slice(0, 3).join(", "); + const moreSuffix = matchingFiles.length > 3 ? ` and ${matchingFiles.length - 3} more` : ""; let reason = `Modified ${matchingFiles.length} file(s) matching criteria (${sampleFiles}${moreSuffix})`; - if (rule.requiredAnyLabels && rule.requiredAnyLabels.length > 0) { - const matchedLabel = rawLabels.find(l => rule.requiredAnyLabels.map(r => r.toLowerCase()).includes(l)); + if (Array.isArray(requiredLabels) && requiredLabels.length > 0) { + const matchedLabel = rawLabels.find(l => requiredLabels.map(r => r.toLowerCase()).includes(l)); reason = `PR labeled '${matchedLabel}' and modified ${matchingFiles.length} file(s) (${sampleFiles}${moreSuffix})`; } @@ -193,6 +235,7 @@ function evaluateBadges(prContext = {}, rules = defaultRules) { module.exports = { SUPPORTED_REPOSITORIES, + UNIVERSAL_EXCLUSIONS, isSupportedRepository, evaluateBadges, matchGlob, diff --git a/utils/badge-evaluator.test.js b/utils/badge-evaluator.test.js index 5f4cbd0..20c2752 100644 --- a/utils/badge-evaluator.test.js +++ b/utils/badge-evaluator.test.js @@ -1,220 +1,458 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { evaluateBadges, matchGlob } = require('./badge-evaluator'); +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + evaluateBadges, + matchGlob, + UNIVERSAL_EXCLUSIONS, + isSupportedRepository +} = require("./badge-evaluator"); -test('matchGlob utility handles patterns accurately', () => { - // Exact matches - assert.equal(matchGlob('main.go', 'main.go'), true); - assert.equal(matchGlob('main.go', 'server/main.go'), false); - - // Single asterisk within path segment - assert.equal(matchGlob('src/*.ts', 'src/index.ts'), true); - assert.equal(matchGlob('src/*.ts', 'src/sub/index.ts'), false); - - // Double asterisk directory wildcard - assert.equal(matchGlob('src/**', 'src/components/button.tsx'), true); - assert.equal(matchGlob('src/**', 'packages/theme/index.ts'), false); +test("matchGlob utility handles patterns accurately", () => { + // Directory wildcards + assert.equal(matchGlob("src/**", "src/components/button.tsx"), true); + assert.equal(matchGlob("src/**", "src/index.ts"), true); + assert.equal(matchGlob("src/**", "packages/theme/index.ts"), false); // Test and spec exclusion globs - assert.equal(matchGlob('**/*.test.*', 'ui/components/button.test.tsx'), true); - assert.equal(matchGlob('**/*.test.*', 'ui/components/button.tsx'), false); - assert.equal(matchGlob('**/__tests__/**', 'src/__tests__/app.test.js'), true); + assert.equal(matchGlob("**/*.test.*", "ui/components/button.test.tsx"), true); + assert.equal(matchGlob("**/*.test.*", "ui/components/button.tsx"), false); + assert.equal(matchGlob("**/*_test.go", "server/handlers/patterns_test.go"), true); + assert.equal(matchGlob("**/*_test.go", "server/handlers/patterns.go"), false); + assert.equal(matchGlob("**/__tests__/**", "src/__tests__/app.test.js"), true); + assert.equal(matchGlob("**/__tests__/**", "src/components/app.js"), false); }); -test('sistent-contributor badge evaluation: positive & negative paths', () => { +test("sistent-contributor badge evaluation: positive & negative paths", () => { // Qualifying files in src const srcResult = evaluateBadges({ - repository: 'layer5io/sistent', - changedFiles: ['src/components/button.tsx', 'package.json'] + repository: "layer5io/sistent", + changedFiles: ["src/components/button.tsx"] }); - assert.equal(srcResult.eligibleBadges.length, 1); - assert.equal(srcResult.eligibleBadges[0].slug, 'sistent-contributor'); + const srcSlugs = srcResult.eligibleBadges.map(b => b.slug); + assert.ok(srcSlugs.includes("sistent-contributor")); - // Qualifying files in examples (prevents false negative for demo contributors) + // Qualifying files in examples const exampleResult = evaluateBadges({ - repository: 'layer5io/sistent', - changedFiles: ['examples/nextjs-sample/pages/index.tsx'] + repository: "layer5io/sistent", + changedFiles: ["examples/nextjs-sample/pages/index.tsx"] + }); + const exampleSlugs = exampleResult.eligibleBadges.map(b => b.slug); + assert.ok(exampleSlugs.includes("sistent-contributor")); + + // system/** is docs only - does NOT qualify for sistent-contributor + const systemResult = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["system/docs/guidelines.md"] + }); + const systemSlugs = systemResult.eligibleBadges.map(b => b.slug); + assert.ok(!systemSlugs.includes("sistent-contributor")); + + // Config and build files (package.json, tsconfig.json, Makefile) do NOT qualify + const configResult = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["package.json", "tsconfig.json", "Makefile"] + }); + assert.equal(configResult.eligibleBadges.length, 0); + + // Non-existent or proposal-unsupported paths (packages/**, scripts/**) do NOT qualify + const unsupportedResult = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["packages/theme/index.js", "scripts/build.sh"] }); - assert.equal(exampleResult.eligibleBadges.length, 1); - assert.equal(exampleResult.eligibleBadges[0].slug, 'sistent-contributor'); + assert.equal(unsupportedResult.eligibleBadges.length, 0); // Disqualifying root metadata / non-code const disqualified = evaluateBadges({ - repository: 'layer5io/sistent', - changedFiles: ['.github/workflows/ci.yml', '.gitignore', 'LICENSE', 'CODE_OF_CONDUCT.md', 'README.md', 'CONTRIBUTING.md'] + repository: "layer5io/sistent", + changedFiles: [".github/workflows/ci.yml", ".gitignore", "LICENSE", "CODE_OF_CONDUCT.md", "README.md", "CONTRIBUTING.md"] }); assert.equal(disqualified.eligibleBadges.length, 0); // Case insensitive repository check const caseInsensitive = evaluateBadges({ - repository: 'Layer5IO/Sistent', - changedFiles: ['packages/theme/index.js'] + repository: "Layer5IO/Sistent", + changedFiles: ["src/index.ts"] }); - assert.equal(caseInsensitive.eligibleBadges.length, 1); - assert.equal(caseInsensitive.eligibleBadges[0].slug, 'sistent-contributor'); + const ciSlugs = caseInsensitive.eligibleBadges.map(b => b.slug); + assert.ok(ciSlugs.includes("sistent-contributor")); }); -test('meshery core vs meshery-docs evaluation in meshery/meshery', () => { +test("meshery core vs meshery-docs evaluation in meshery/meshery", () => { // Core functional backend code modification const coreResult = evaluateBadges({ - repository: 'meshery/meshery', - changedFiles: ['server/handlers/patterns.go', 'mesheryctl/cmd/system.go'] + repository: "meshery/meshery", + changedFiles: ["server/handlers/patterns.go", "mesheryctl/cmd/system.go"] }); const coreSlugs = coreResult.eligibleBadges.map(b => b.slug); - assert.ok(coreSlugs.includes('meshery')); - assert.ok(!coreSlugs.includes('meshery-docs')); + assert.ok(coreSlugs.includes("meshery")); + assert.ok(!coreSlugs.includes("meshery-docs")); - // Core functional frontend UI modification (prevents false negative for UI contributors) + // Core functional frontend UI modification const uiResult = evaluateBadges({ - repository: 'meshery/meshery', - changedFiles: ['ui/components/Navigator.tsx'] + repository: "meshery/meshery", + changedFiles: ["ui/components/Navigator.tsx"] }); const uiSlugs = uiResult.eligibleBadges.map(b => b.slug); - assert.ok(uiSlugs.includes('meshery'), 'UI contributors must earn meshery core badge'); + assert.ok(uiSlugs.includes("meshery"), "UI contributors must earn meshery core badge"); + + // provider-ui qualifies for meshery + const providerUiResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["provider-ui/components/ProviderCard.tsx"] + }); + assert.ok(providerUiResult.eligibleBadges.some(b => b.slug === "meshery")); + + // models/** must NOT qualify for meshery (strictly reserved for meshery-catalog) + const modelsResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["models/patterns/design.json"] + }); + assert.ok(!modelsResult.eligibleBadges.some(b => b.slug === "meshery"), "models/** must NOT qualify for meshery"); + + // install/** must NOT qualify for meshery + const installResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["install/kubernetes/helm/values.yaml"] + }); + assert.ok(!installResult.eligibleBadges.some(b => b.slug === "meshery"), "install/** must NOT qualify for meshery"); - // Documentation-only modification + // root main.go, go.mod, go.sum, Makefile do NOT qualify + const buildFilesResult = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["main.go", "go.mod", "go.sum", "Makefile"] + }); + assert.equal(buildFilesResult.eligibleBadges.length, 0); + + // Documentation-only modification (.md / .mdx) const docsResult = evaluateBadges({ - repository: 'meshery/meshery', - changedFiles: ['docs/concepts/architecture.md', 'docs/install/index.md'] + repository: "meshery/meshery", + changedFiles: ["docs/concepts/architecture.md", "docs/install/index.mdx"] }); const docsSlugs = docsResult.eligibleBadges.map(b => b.slug); - assert.ok(!docsSlugs.includes('meshery'), 'Docs-only PR must not earn meshery core badge'); - assert.ok(docsSlugs.includes('meshery-docs'), 'Must earn meshery-docs badge'); + assert.ok(!docsSlugs.includes("meshery"), "Docs-only PR must not earn meshery core badge"); + assert.ok(docsSlugs.includes("meshery-docs"), "Must earn meshery-docs badge"); + + // Non-doc file under docs/** does NOT qualify for meshery-docs + const nonDocInDocs = evaluateBadges({ + repository: "meshery/meshery", + changedFiles: ["docs/assets/diagram.png", "docs/data/schema.json"] + }); + assert.ok(!nonDocInDocs.eligibleBadges.some(b => b.slug === "meshery-docs")); // Root markdown & governance exclusions const metaResult = evaluateBadges({ - repository: 'meshery/meshery', - changedFiles: ['README.md', 'ROADMAP.md', 'ADOPTERS.md', 'GOVERNANCE.md', '.github/workflows/test.yml'] + repository: "meshery/meshery", + changedFiles: ["README.md", "ROADMAP.md", "ADOPTERS.md", "GOVERNANCE.md", "VISION.md", "CONTRIBUTING.md", ".github/workflows/test.yml"] }); assert.equal(metaResult.eligibleBadges.length, 0); // Mixed PR modifying both server and docs const mixedResult = evaluateBadges({ - repository: 'meshery/meshery', - changedFiles: ['server/main.go', 'docs/quickstart.md'] + repository: "meshery/meshery", + changedFiles: ["server/main.go", "docs/quickstart.md"] }); const mixedSlugs = mixedResult.eligibleBadges.map(b => b.slug); - assert.ok(mixedSlugs.includes('meshery')); - assert.ok(mixedSlugs.includes('meshery-docs')); + assert.ok(mixedSlugs.includes("meshery")); + assert.ok(mixedSlugs.includes("meshery-docs")); }); -test('meshery-operator and meshsync badge evaluation: positive & negative paths', () => { - // Operator controller modification +test("meshery-operator and meshsync badge evaluation: positive & negative paths", () => { + // Operator controllers, api, pkg, cmd qualify const opResult = evaluateBadges({ - repository: 'meshery/meshery-operator', - changedFiles: ['controllers/meshery_controller.go', 'api/v1alpha1/types.go'] + repository: "meshery/meshery-operator", + changedFiles: ["controllers/meshery_controller.go", "api/v1alpha1/types.go", "pkg/client.go", "cmd/main.go"] }); assert.equal(opResult.eligibleBadges.length, 1); - assert.equal(opResult.eligibleBadges[0].slug, 'meshery-operator'); + assert.equal(opResult.eligibleBadges[0].slug, "meshery-operator"); - // Operator bundle/manifest modification + // Operator bundle/** and config/** do NOT qualify const opBundle = evaluateBadges({ - repository: 'meshery/meshery-operator', - changedFiles: ['bundle/manifests/meshery.clusterserviceversion.yaml'] + repository: "meshery/meshery-operator", + changedFiles: ["bundle/manifests/meshery.csv.yaml", "config/rbac/role.yaml"] + }); + assert.equal(opBundle.eligibleBadges.length, 0); + + // Operator generated files zz_generated* do NOT qualify + const opGenerated = evaluateBadges({ + repository: "meshery/meshery-operator", + changedFiles: ["api/v1alpha1/zz_generated.deepcopy.go"] + }); + assert.equal(opGenerated.eligibleBadges.length, 0); + + // Operator go.mod, go.sum do NOT qualify + const opDeps = evaluateBadges({ + repository: "meshery/meshery-operator", + changedFiles: ["go.mod", "go.sum"] }); - assert.equal(opBundle.eligibleBadges.length, 1); - assert.equal(opBundle.eligibleBadges[0].slug, 'meshery-operator'); + assert.equal(opDeps.eligibleBadges.length, 0); - // MeshSync internal and plugin logic modification + // MeshSync internal, pkg, meshsync qualify const syncResult = evaluateBadges({ - repository: 'meshery/meshsync', - changedFiles: ['internal/daemon/sync.go', 'plugins/discovery.go'] + repository: "meshery/meshsync", + changedFiles: ["internal/daemon/sync.go", "pkg/discovery.go", "meshsync/server.go"] }); assert.equal(syncResult.eligibleBadges.length, 1); - assert.equal(syncResult.eligibleBadges[0].slug, 'meshsync'); + assert.equal(syncResult.eligibleBadges[0].slug, "meshsync"); + + // MeshSync plugins/** and cache/** do NOT qualify (unsupported / non-existent) + const syncUnsupported = evaluateBadges({ + repository: "meshery/meshsync", + changedFiles: ["plugins/discovery.go", "cache/store.go"] + }); + assert.equal(syncUnsupported.eligibleBadges.length, 0); + + // MeshSync integration-tests/** and go.mod, go.sum do NOT qualify + const syncExcludes = evaluateBadges({ + repository: "meshery/meshsync", + changedFiles: ["integration-tests/run.sh", "go.mod", "go.sum"] + }); + assert.equal(syncExcludes.eligibleBadges.length, 0); // Operator non-code metadata excluded const opMeta = evaluateBadges({ - repository: 'meshery/meshery-operator', - changedFiles: ['README.md', 'LICENSE', '.github/workflows/ci.yml', 'CODE_OF_CONDUCT.md'] + repository: "meshery/meshery-operator", + changedFiles: ["README.md", "LICENSE", ".github/workflows/ci.yml", "CODE_OF_CONDUCT.md"] }); assert.equal(opMeta.eligibleBadges.length, 0); }); -test('meshery-docs in layer5io/docs', () => { +test("meshery-docs in layer5io/docs and meshery/meshery", () => { + // layer5io/docs: content/**/*.md and content/**/*.mdx qualify const docsResult = evaluateBadges({ - repository: 'layer5io/docs', - changedFiles: ['content/overview/index.md', 'pages/getting-started.tsx'] + repository: "layer5io/docs", + changedFiles: ["content/overview/index.md", "content/setup/install.mdx"] }); assert.equal(docsResult.eligibleBadges.length, 1); - assert.equal(docsResult.eligibleBadges[0].slug, 'meshery-docs'); + assert.equal(docsResult.eligibleBadges[0].slug, "meshery-docs"); + + // layer5io/docs: pages/** does NOT qualify (canonical exclusion) + const pagesResult = evaluateBadges({ + repository: "layer5io/docs", + changedFiles: ["pages/getting-started.tsx", "pages/index.js"] + }); + assert.equal(pagesResult.eligibleBadges.length, 0); + + // layer5io/docs: non-markdown under content/** does NOT qualify + const nonMdContent = evaluateBadges({ + repository: "layer5io/docs", + changedFiles: ["content/assets/logo.png"] + }); + assert.equal(nonMdContent.eligibleBadges.length, 0); + + // Excluded doc directories (catalog, static, meetings, etc.) do NOT qualify + const excludedDocs = evaluateBadges({ + repository: "layer5io/docs", + changedFiles: ["catalog/item.md", "data/nav.json", "meetings/notes.md", "static/script.js"] + }); + assert.equal(excludedDocs.eligibleBadges.length, 0); }); -test('meshery-catalog evaluation in meshery.io and meshery', () => { - // In meshery/meshery.io +test("meshery-catalog evaluation in meshery.io and meshery", () => { + // In meshery/meshery.io: catalog/** qualifies const catalogWeb = evaluateBadges({ - repository: 'meshery/meshery.io', - changedFiles: ['collections/catalog/wasm-filter.json', 'catalog/kubernetes/item.yaml'] + repository: "meshery/meshery.io", + changedFiles: ["catalog/kubernetes/item.yaml"] }); assert.equal(catalogWeb.eligibleBadges.length, 1); - assert.equal(catalogWeb.eligibleBadges[0].slug, 'meshery-catalog'); + assert.equal(catalogWeb.eligibleBadges[0].slug, "meshery-catalog"); + + // In meshery/meshery.io: collections/_catalog/** qualifies + const underscoreCatalog = evaluateBadges({ + repository: "meshery/meshery.io", + changedFiles: ["collections/_catalog/wasm-filter.json"] + }); + assert.equal(underscoreCatalog.eligibleBadges.length, 1); + assert.equal(underscoreCatalog.eligibleBadges[0].slug, "meshery-catalog"); + + // In meshery/meshery.io: collections/catalog/** (missing underscore) does NOT qualify + const badPathCatalog = evaluateBadges({ + repository: "meshery/meshery.io", + changedFiles: ["collections/catalog/wasm-filter.json"] + }); + assert.equal(badPathCatalog.eligibleBadges.length, 0); - // In meshery/meshery models + // In meshery/meshery: models/** qualifies for meshery-catalog const catalogModels = evaluateBadges({ - repository: 'meshery/meshery', - changedFiles: ['models/patterns/design.json'] + repository: "meshery/meshery", + changedFiles: ["models/patterns/design.json"] }); - const slugs = catalogModels.eligibleBadges.map(b => b.slug); - assert.ok(slugs.includes('meshery-catalog')); + assert.equal(catalogModels.eligibleBadges.length, 1); + assert.equal(catalogModels.eligibleBadges[0].slug, "meshery-catalog"); + + // CRITICAL REGRESSION: same meshery models/** must NOT qualify for meshery core + assert.ok(!catalogModels.eligibleBadges.some(b => b.slug === "meshery"), "models/** must be excluded from meshery core"); }); -test('landscape badge evaluation in layer5io/layer5', () => { - // Modifying landscape data +test("landscape badge evaluation in layer5io/layer5", () => { + // Modifying landscape data strictly qualifies const landscapeResult = evaluateBadges({ - repository: 'layer5io/layer5', - changedFiles: ['src/collections/landscape/service-mesh.json'] + repository: "layer5io/layer5", + changedFiles: ["src/collections/landscape/service-mesh.json"] }); assert.equal(landscapeResult.eligibleBadges.length, 1); - assert.equal(landscapeResult.eligibleBadges[0].slug, 'landscape'); + assert.equal(landscapeResult.eligibleBadges[0].slug, "landscape"); - // Modifying blog / news collections must be excluded + // Modifying blog / news / members collections must be excluded const blogResult = evaluateBadges({ - repository: 'layer5io/layer5', - changedFiles: ['src/collections/blog/announcement.md', 'src/collections/news/update.md'] + repository: "layer5io/layer5", + changedFiles: [ + "src/collections/blog/announcement.md", + "src/collections/news/update.md", + "src/collections/members/profile.json" + ] }); assert.equal(blogResult.eligibleBadges.length, 0); }); -test('ui-ux badge evaluation with labels and frontend files', () => { - // Qualifying: area/ui label + meshery UI component +test("ui-ux badge evaluation with repository-specific rules", () => { + // 1. meshery/meshery: requires BOTH component/ui label AND ui/** or provider-ui/** const mesheryUi = evaluateBadges({ - repository: 'meshery/meshery', - labels: ['area/ui', 'enhancement'], - changedFiles: ['ui/components/Navigator.tsx'] + repository: "meshery/meshery", + labels: ["component/ui", "enhancement"], + changedFiles: ["ui/components/Navigator.tsx"] + }); + assert.ok(mesheryUi.eligibleBadges.some(b => b.slug === "ui-ux")); + + const mesheryProviderUi = evaluateBadges({ + repository: "meshery/meshery", + labels: [{ name: "component/ui" }], + changedFiles: ["provider-ui/components/Card.tsx"] + }); + assert.ok(mesheryProviderUi.eligibleBadges.some(b => b.slug === "ui-ux")); + + // meshery without component/ui label does NOT qualify + const mesheryNoLabel = evaluateBadges({ + repository: "meshery/meshery", + labels: ["bug"], + changedFiles: ["ui/components/Navigator.tsx"] + }); + assert.ok(!mesheryNoLabel.eligibleBadges.some(b => b.slug === "ui-ux")); + + // meshery with component/ui label but unrelated path does NOT qualify + const mesheryUnrelatedPath = evaluateBadges({ + repository: "meshery/meshery", + labels: ["component/ui"], + changedFiles: ["server/handlers/patterns.go"] + }); + assert.ok(!mesheryUnrelatedPath.eligibleBadges.some(b => b.slug === "ui-ux")); + + // meshery with legacy area/ui or area/ux labels (without component/ui) does NOT qualify + const mesheryLegacyLabel = evaluateBadges({ + repository: "meshery/meshery", + labels: ["area/ui", "area/ux"], + changedFiles: ["ui/components/Navigator.tsx"] + }); + assert.ok(!mesheryLegacyLabel.eligibleBadges.some(b => b.slug === "ui-ux")); + + // 2. layer5io/sistent: qualifies on src/** or system/** without any label required + const sistentSrc = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["src/components/Modal/index.tsx"] + }); + assert.ok(sistentSrc.eligibleBadges.some(b => b.slug === "ui-ux")); + + const sistentSystem = evaluateBadges({ + repository: "layer5io/sistent", + changedFiles: ["system/theme/colors.ts"] }); - assert.ok(mesheryUi.eligibleBadges.some(b => b.slug === 'ui-ux')); + assert.ok(sistentSystem.eligibleBadges.some(b => b.slug === "ui-ux")); - // Qualifying: area/ux label + sistent component - const sistentUi = evaluateBadges({ - repository: 'layer5io/sistent', - labels: [{ name: 'area/ux' }], - changedFiles: ['src/components/Modal/index.tsx'] + // 3. layer5io/layer5: qualifies on frontend directories without any label required + const layer5Components = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/components/Banner/index.tsx"] }); - const sistentSlugs = sistentUi.eligibleBadges.map(b => b.slug); - assert.ok(sistentSlugs.includes('ui-ux')); - assert.ok(sistentSlugs.includes('sistent-contributor')); + assert.ok(layer5Components.eligibleBadges.some(b => b.slug === "ui-ux")); - // Qualifying: area/ui + layer5 section - const layer5Ui = evaluateBadges({ - repository: 'layer5io/layer5', - labels: ['area/ui'], - changedFiles: ['src/sections/Home/Banner.tsx'] + const layer5Sections = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/sections/Home/Hero.tsx"] }); - assert.ok(layer5Ui.eligibleBadges.some(b => b.slug === 'ui-ux')); + assert.ok(layer5Sections.eligibleBadges.some(b => b.slug === "ui-ux")); - // Missing required label even if frontend file modified - const noLabel = evaluateBadges({ - repository: 'meshery/meshery', - labels: ['bug'], - changedFiles: ['ui/components/Navigator.tsx'] + const layer5Templates = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/templates/blog-single.tsx"] }); - assert.ok(!noLabel.eligibleBadges.some(b => b.slug === 'ui-ux')); + assert.ok(layer5Templates.eligibleBadges.some(b => b.slug === "ui-ux")); - // Only test files modified with area/ui label - const testFilesOnly = evaluateBadges({ - repository: 'meshery/meshery', - labels: ['area/ui'], - changedFiles: ['ui/components/__tests__/Navigator.test.tsx', 'package-lock.json'] + const layer5Pages = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/pages/index.tsx"] }); - assert.ok(!testFilesOnly.eligibleBadges.some(b => b.slug === 'ui-ux')); + assert.ok(layer5Pages.eligibleBadges.some(b => b.slug === "ui-ux")); + + // layer5 exclusions: src/collections/** and src/assets/** do NOT qualify for ui-ux + const layer5Excluded = evaluateBadges({ + repository: "layer5io/layer5", + changedFiles: ["src/collections/blog/post.md", "src/assets/images/logo.png"] + }); + assert.ok(!layer5Excluded.eligibleBadges.some(b => b.slug === "ui-ux")); +}); + +test("Universal exclusions consistently exclude tests, lockfiles, and repository governance", () => { + const repositories = [ + { repo: "layer5io/sistent", validPath: "src/components/button.tsx" }, + { repo: "meshery/meshery", validPath: "server/handlers/patterns.go" }, + { repo: "meshery/meshery-operator", validPath: "controllers/operator.go" }, + { repo: "meshery/meshsync", validPath: "internal/sync.go" } + ]; + + for (const { repo, validPath } of repositories) { + // Tests: *.test.*, *_test.go, __tests__/** + assert.equal( + evaluateBadges({ repository: repo, changedFiles: [validPath.replace(/\.tsx$|\.go$/, ".test.tsx")] }).eligibleBadges.length, + 0, + `${repo}: *.test.* must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: [validPath.replace(/\.tsx$|\.go$/, "_test.go")] }).eligibleBadges.length, + 0, + `${repo}: *_test.go must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["src/__tests__/unit.js"] }).eligibleBadges.length, + 0, + `${repo}: __tests__/** must be excluded` + ); + + // Lockfiles + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["package-lock.json", "ui/package-lock.json"] }).eligibleBadges.length, + 0, + `${repo}: package-lock.json must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["yarn.lock", "nested/yarn.lock"] }).eligibleBadges.length, + 0, + `${repo}: yarn.lock must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["go.sum", "server/go.sum"] }).eligibleBadges.length, + 0, + `${repo}: go.sum must be excluded` + ); + + // Repository governance + assert.equal( + evaluateBadges({ repository: repo, changedFiles: [".github/workflows/ci.yml", ".github/dependabot.yml"] }).eligibleBadges.length, + 0, + `${repo}: .github/** must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["LICENSE"] }).eligibleBadges.length, + 0, + `${repo}: LICENSE must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["README.md"] }).eligibleBadges.length, + 0, + `${repo}: README.md must be excluded` + ); + assert.equal( + evaluateBadges({ repository: repo, changedFiles: ["CONTRIBUTING.md", "CONTRIBUTING-DOCS.md"] }).eligibleBadges.length, + 0, + `${repo}: CONTRIBUTING*.md must be excluded` + ); + } }); diff --git a/utils/badge-rules.json b/utils/badge-rules.json index 7a38254..0b801ad 100644 --- a/utils/badge-rules.json +++ b/utils/badge-rules.json @@ -4,25 +4,19 @@ "name": "Sistent Contributor", "ruleId": "rule-sistent-contributor", "description": "Consistent & impactful contributions to Sistent design system and components", - "repositories": ["layer5io/sistent"], + "repositories": [ + "layer5io/sistent" + ], "includePatterns": [ "src/**", - "packages/**", + "examples/**" + ], + "excludePatterns": [ "system/**", - "examples/**", - "scripts/**", + "src/assets/**", "package.json", "tsconfig.json", "Makefile" - ], - "excludePatterns": [ - ".github/**", - ".gitignore", - "LICENSE", - "CODE_OF_CONDUCT.md", - "README.md", - "CONTRIBUTING*.md", - "MAINTAINERS.md" ] }, { @@ -30,21 +24,26 @@ "name": "Meshery", "ruleId": "rule-meshery-core", "description": "Consistent & impactful contributions to Meshery core functional codebase", - "repositories": ["meshery/meshery"], + "repositories": [ + "meshery/meshery" + ], "includePatterns": [ "server/**", "mesheryctl/**", + "ui/**", + "provider-ui/**" + ], + "excludePatterns": [ "models/**", "install/**", - "ui/**", - "provider-ui/**", "main.go", - "Makefile", "go.mod", - "go.sum" - ], - "excludePatterns": [ + "go.sum", + "Makefile", "docs/**", + "ui/docs/**", + "ui/public/**", + "ui/scripts/**", "README.md", "ROADMAP.md", "ADOPTERS.md", @@ -59,23 +58,24 @@ "name": "Meshery Operator", "ruleId": "rule-meshery-operator", "description": "Contributions to Meshery Operator controllers, APIs, and manifests", - "repositories": ["meshery/meshery-operator"], + "repositories": [ + "meshery/meshery-operator" + ], "includePatterns": [ "controllers/**", "api/**", "pkg/**", + "cmd/**" + ], + "excludePatterns": [ + "zz_generated*", + "**/zz_generated*", "bundle/**", "config/**", "main.go", "Makefile", "go.mod", "go.sum" - ], - "excludePatterns": [ - ".github/**", - "LICENSE", - "README.md", - "CODE_OF_CONDUCT.md" ] }, { @@ -83,22 +83,22 @@ "name": "MeshSync", "ruleId": "rule-meshsync", "description": "Contributions to MeshSync discovery daemon logic and plugins", - "repositories": ["meshery/meshsync"], + "repositories": [ + "meshery/meshsync" + ], "includePatterns": [ "internal/**", "pkg/**", + "meshsync/**" + ], + "excludePatterns": [ + "integration-tests/**", "plugins/**", "cache/**", "main.go", "Makefile", "go.mod", "go.sum" - ], - "excludePatterns": [ - ".github/**", - "LICENSE", - "README.md", - "CODE_OF_CONDUCT.md" ] }, { @@ -112,17 +112,26 @@ ], "repoSpecificIncludePatterns": { "meshery/meshery": [ - "docs/**" + "docs/**/*.md", + "docs/**/*.mdx" ], "layer5io/docs": [ - "content/**", - "pages/**" + "content/**/*.md", + "content/**/*.mdx" ] }, "excludePatterns": [ - ".github/**", - "LICENSE", - "README.md" + "pages/**", + "catalog/**", + "integrations/**", + "data/**", + "meetings/**", + "static/**", + "archive/**", + "proposals/**", + "layouts/**", + "themes/**", + "assets/**" ] }, { @@ -137,34 +146,29 @@ "repoSpecificIncludePatterns": { "meshery/meshery.io": [ "catalog/**", - "collections/catalog/**" + "collections/_catalog/**" ], "meshery/meshery": [ "models/**" ] }, - "excludePatterns": [ - ".github/**", - "LICENSE", - "README.md" - ] + "excludePatterns": [] }, { "slug": "landscape", "name": "Landscape", "ruleId": "rule-landscape", "description": "Contributions to Layer5 Landscape collection", - "repositories": ["layer5io/layer5"], + "repositories": [ + "layer5io/layer5" + ], "includePatterns": [ "src/collections/landscape/**" ], "excludePatterns": [ "src/collections/blog/**", "src/collections/news/**", - "src/collections/members/**", - ".github/**", - "LICENSE", - "README.md" + "src/collections/members/**" ] }, { @@ -177,10 +181,11 @@ "layer5io/sistent", "layer5io/layer5" ], - "requiredAnyLabels": [ - "area/ui", - "area/ux" - ], + "repoSpecificRequiredAnyLabels": { + "meshery/meshery": [ + "component/ui" + ] + }, "repoSpecificIncludePatterns": { "meshery/meshery": [ "ui/**", @@ -188,8 +193,7 @@ ], "layer5io/sistent": [ "src/**", - "system/**", - "examples/**" + "system/**" ], "layer5io/layer5": [ "src/components/**", @@ -199,11 +203,8 @@ ] }, "excludePatterns": [ - "**/__tests__/**", - "**/*.test.*", - "**/*.spec.*", - "package-lock.json", - "yarn.lock" + "src/collections/**", + "src/assets/**" ] } ]