From 107d7b1ac4ba3fe234a56bb8719532c2eb878708 Mon Sep 17 00:00:00 2001 From: Will Sarg <12886992+willsarg@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:54:10 -0500 Subject: [PATCH] ci: add safe pull request intake sanity checks (#570) * fix(workflows): standardize runner configuration for security jobs * ci(actionlint): add Blacksmith runner label to config Add blacksmith-2vcpu-ubuntu-2404 to actionlint self-hosted-runner labels config to suppress "unknown label" warnings during workflow linting. This label is used across all workflows after the Blacksmith migration. * fix(actionlint): adjust indentation for self-hosted runner labels * feat(security): enhance security workflow with CodeQL analysis steps * fix(security): update CodeQL action to version 4 for improved analysis * fix(security): remove duplicate permissions in security workflow * fix(security): revert CodeQL action to v3 for stability The v4 version was causing workflow file validation failures. Reverting to proven v3 version that is working on main branch. * fix(security): remove duplicate permissions causing workflow validation failure The permissions block had duplicate security-events and actions keys, which caused YAML validation errors and prevented workflow execution. Fixes: workflow file validation failures on main branch * fix(security): remove pull_request trigger to reduce costs * fix(security): restore PR trigger but skip codeql on PRs * fix(security): resolve YAML syntax error in security workflow * refactor(security): split CodeQL into dedicated scheduled workflow * fix(security): update workflow name to Rust Package Security Audit * fix(codeql): remove push trigger, keep schedule and on-demand only * feat(codeql): add CodeQL configuration file to ignore specific paths * Potential fix for code scanning alert no. 39: Hard-coded cryptographic value Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(ci): resolve auto-response workflow merge markers * fix(build): restore ChannelMessage reply_target usage * ci(workflows): run workflow sanity on workflow pushes for all branches * ci(workflows): rename auto-response workflow to PR Auto Responder * ci(workflows): require owner approval for workflow file changes * ci: add lint-first PR feedback gate * ci(workflows): split label policy checks from workflow sanity * ci(workflows): consolidate policy and rust workflow setup * ci: add safe pull request intake sanity checks --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/pr-intake-sanity.yml | 179 +++++++++++++++++++++++++ docs/ci-map.md | 10 +- 2 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/pr-intake-sanity.yml diff --git a/.github/workflows/pr-intake-sanity.yml b/.github/workflows/pr-intake-sanity.yml new file mode 100644 index 0000000..10a597e --- /dev/null +++ b/.github/workflows/pr-intake-sanity.yml @@ -0,0 +1,179 @@ +name: PR Intake Sanity + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited, ready_for_review] + +concurrency: + group: pr-intake-sanity-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + intake: + name: Intake Sanity + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Run safe PR intake checks + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr = context.payload.pull_request; + if (!pr) return; + + const marker = ""; + const requiredSections = [ + "## Summary", + "## Validation Evidence (required)", + "## Security Impact (required)", + "## Privacy and Data Hygiene (required)", + "## Rollback Plan (required)", + ]; + const body = pr.body || ""; + + const missingSections = requiredSections.filter((section) => !body.includes(section)); + const missingFields = []; + const requiredFieldChecks = [ + ["summary problem", /- Problem:\s*\S+/m], + ["summary why it matters", /- Why it matters:\s*\S+/m], + ["summary what changed", /- What changed:\s*\S+/m], + ["validation commands", /Commands and result summary:\s*[\s\S]*```/m], + ["security risk/mitigation", /- New permissions\/capabilities\?\s*\(`Yes\/No`\):\s*\S+/m], + ["privacy status", /- Data-hygiene status\s*\(`pass\|needs-follow-up`\):\s*\S+/m], + ["rollback plan", /- Fast rollback command\/path:\s*\S+/m], + ]; + for (const [name, pattern] of requiredFieldChecks) { + if (!pattern.test(body)) { + missingFields.push(name); + } + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + + const formatProblems = []; + for (const file of files) { + const patch = file.patch || ""; + if (!patch) continue; + const lines = patch.split("\n"); + for (let idx = 0; idx < lines.length; idx += 1) { + const line = lines[idx]; + if (!line.startsWith("+") || line.startsWith("+++")) continue; + const added = line.slice(1); + const lineNo = idx + 1; + if (/\t/.test(added)) { + formatProblems.push(`${file.filename}:patch#${lineNo} contains tab characters`); + } + if (/[ \t]+$/.test(added)) { + formatProblems.push(`${file.filename}:patch#${lineNo} contains trailing whitespace`); + } + if (/^(<<<<<<<|=======|>>>>>>>)/.test(added)) { + formatProblems.push(`${file.filename}:patch#${lineNo} contains merge conflict markers`); + } + } + } + + const workflowFilesChanged = files + .map((file) => file.filename) + .filter((name) => name.startsWith(".github/workflows/")); + + const failures = []; + if (missingSections.length > 0) { + failures.push(`Missing required PR template sections: ${missingSections.join(", ")}`); + } + if (missingFields.length > 0) { + failures.push(`Incomplete required PR template fields: ${missingFields.join(", ")}`); + } + if (formatProblems.length > 0) { + failures.push(`Formatting/safety issues in added lines (${formatProblems.length})`); + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + per_page: 100, + }); + const existing = comments.find((comment) => (comment.body || "").includes(marker)); + + if (failures.length === 0) { + if (existing) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: existing.id, + }); + } + core.info("PR intake sanity checks passed."); + return; + } + + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const details = []; + if (formatProblems.length > 0) { + details.push(...formatProblems.slice(0, 20).map((entry) => `- ${entry}`)); + if (formatProblems.length > 20) { + details.push(`- ...and ${formatProblems.length - 20} more issue(s)`); + } + } + + const ownerApprovalNote = workflowFilesChanged.length > 0 + ? [ + "", + "Workflow files changed in this PR:", + ...workflowFilesChanged.map((name) => `- \`${name}\``), + "", + "Reminder: workflow changes require owner approval via `CI Required Gate`.", + ].join("\n") + : ""; + + const commentBody = [ + marker, + "### PR intake checks failed", + "", + "Fast safe checks ran before full CI and found issues:", + ...failures.map((entry) => `- ${entry}`), + "", + "Action items:", + "1. Complete the required PR template sections/fields.", + "2. Remove tabs, trailing whitespace, and conflict markers from added lines.", + "3. Re-run local checks before pushing:", + " - `./scripts/ci/rust_quality_gate.sh`", + " - `./scripts/ci/rust_strict_delta_gate.sh`", + " - `./scripts/ci/docs_quality_gate.sh`", + "", + `Run logs: ${runUrl}`, + "", + "Detected line issues (sample):", + ...(details.length > 0 ? details : ["- none"]), + ownerApprovalNote, + ].join("\n"); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: commentBody, + }); + } + + core.setFailed("PR intake sanity checks failed. See sticky comment for details."); diff --git a/docs/ci-map.md b/docs/ci-map.md index 842bca2..344ed6f 100644 --- a/docs/ci-map.md +++ b/docs/ci-map.md @@ -16,6 +16,8 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`) - Purpose: lint GitHub workflow files (`actionlint`, tab checks) - Recommended for workflow-changing PRs +- `.github/workflows/pr-intake-sanity.yml` (`PR Intake Sanity`) + - Purpose: safe pre-CI PR checks (template completeness, added-line tabs/trailing-whitespace/conflict markers) with immediate sticky feedback comment ### Non-Blocking but Important @@ -64,6 +66,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - `Release`: tag push (`v*`) - `Security Audit`: push to `main`, PRs to `main`, weekly schedule - `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change +- `PR Intake Sanity`: `pull_request_target` on opened/reopened/synchronize/edited/ready_for_review - `Label Policy Sanity`: PR/push when `.github/label-policy.json`, `.github/workflows/labeler.yml`, or `.github/workflows/auto-response.yml` changes - `PR Labeler`: `pull_request_target` lifecycle events - `PR Auto Responder`: issue opened/labeled, `pull_request_target` opened/labeled @@ -78,9 +81,10 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u 3. Release failures on tags: inspect `.github/workflows/release.yml`. 4. Security failures: inspect `.github/workflows/security.yml` and `deny.toml`. 5. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`. -6. Label policy parity failures: inspect `.github/workflows/label-policy-sanity.yml`. -7. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci.yml`. -8. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope. +6. PR intake failures: inspect `.github/workflows/pr-intake-sanity.yml` sticky comment and run logs. +7. Label policy parity failures: inspect `.github/workflows/label-policy-sanity.yml`. +8. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci.yml`. +9. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope. ## Maintenance Rules