diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cf15a1a6e..6932fff39c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,12 +158,13 @@ jobs: review-labels: name: Review label gate - needs: detect - if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true') + needs: [detect, supply-chain] + if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true') uses: ./.github/workflows/review-labels.yml with: ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }} + supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }} secrets: inherit osv-scanner: diff --git a/.github/workflows/review-labels.yml b/.github/workflows/review-labels.yml index f0167b4867..737348d1c5 100644 --- a/.github/workflows/review-labels.yml +++ b/.github/workflows/review-labels.yml @@ -27,6 +27,10 @@ on: description: Whether the MCP catalog / installer changed. type: boolean default: false + supply_chain: + description: Whether the critical supply-chain scan found a risk requiring review. + type: boolean + default: false outputs: ci_reviewed: description: Whether the ci-reviewed label is present. Empty when neither input was true. @@ -42,7 +46,7 @@ permissions: jobs: check: name: Review label gate - if: inputs.ci_review || inputs.mcp_catalog + if: inputs.ci_review || inputs.mcp_catalog || inputs.supply_chain runs-on: ubuntu-latest timeout-minutes: 2 outputs: @@ -75,15 +79,17 @@ jobs: env: CI_REVIEW: ${{ inputs.ci_review }} MCP_CATALOG: ${{ inputs.mcp_catalog }} + SUPPLY_CHAIN: ${{ inputs.supply_chain }} LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }} run: | set -euo pipefail - ARGS="" - if [ "$CI_REVIEW" = "true" ]; then ARGS="$ARGS --ci-review"; fi - if [ "$MCP_CATALOG" = "true" ]; then ARGS="$ARGS --mcp-catalog"; fi - if [ "$LABEL_PRESENT" = "true" ]; then ARGS="$ARGS --label-present"; fi + args=() + if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi + if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi + if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi + if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi - python3 scripts/ci/emit_review_status.py $ARGS --output "$GITHUB_OUTPUT" + python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT" - name: Fail on missing label if: steps.label-check.outputs.ci_reviewed != 'true' diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 1b8a35cb30..47114b3065 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -16,11 +16,12 @@ name: Supply Chain Audit # ``review-labels.yml`` so it can be rerun independently. # # Outputs: -# review_status — JSON array of status objects consumed by the review -# comment assembler (scripts/ci/assemble_review_comment.py). -# Each job (``scan``, ``dep-bounds``) emits its own -# array; an ``aggregate`` job merges them into the -# workflow-level output. +# review_status — JSON array of status objects consumed by the review +# comment assembler (scripts/ci/assemble_review_comment.py). +# critical_findings — "true" when the narrow critical-pattern scan found +# something. The review-label gate consumes this and +# owns the action-required result, so adding +# ``ci-reviewed`` can heal the run on rerun. on: workflow_call: @@ -41,6 +42,9 @@ on: review_status: description: JSON array of review status objects for the review comment assembler. value: ${{ jobs.aggregate.outputs.review_status }} + critical_findings: + description: Whether the critical-pattern scan found a risk requiring maintainer review. + value: ${{ jobs.aggregate.outputs.critical_findings }} permissions: pull-requests: write @@ -54,6 +58,7 @@ jobs: timeout-minutes: 15 outputs: review_status: ${{ steps.emit-status.outputs.review_status }} + critical_findings: ${{ steps.scan.outputs.found }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -165,33 +170,15 @@ jobs: python3 - <<'PYEOF' import json, os - found = os.environ.get("FOUND", "") == "true" - - if found: - with open("/tmp/findings.md", encoding="utf-8") as f: - detail = f.read() - status = [{ - "source": "supply chain", - "results": [{ - "kind": "error", - "title": "Critical supply chain risk", - "summary": "Critical supply chain risk patterns detected in this PR.", - "detail": detail, - "how_to_fix": "Review the flagged code carefully. If intentional, add the `ci-reviewed` label." - }] - }] - else: - status = [] + # The review-label gate renders and blocks critical findings. Keep + # this scan a fact-finder so adding ci-reviewed can rerun the gate + # without requiring the scanner itself to fail again. + status = [] with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: f.write(f"review_status={json.dumps(status)}\n") PYEOF - - name: Fail on critical findings - if: steps.scan.outputs.found == 'true' - run: | - echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the review comment for details." - exit 1 dep-bounds: name: Check PyPI dependency upper bounds @@ -279,12 +266,14 @@ jobs: timeout-minutes: 15 outputs: review_status: ${{ steps.merge.outputs.review_status }} + critical_findings: ${{ steps.merge.outputs.critical_findings }} steps: - name: Merge review statuses id: merge env: SCAN_STATUS: ${{ needs.scan.outputs.review_status }} DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }} + CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }} run: | python3 - <<'PYEOF' import json, os @@ -303,4 +292,5 @@ jobs: with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: f.write(f"review_status={json.dumps(merged)}\n") + f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n") PYEOF diff --git a/scripts/ci/emit_review_status.py b/scripts/ci/emit_review_status.py index dcf58a7a0e..f8c02938d8 100644 --- a/scripts/ci/emit_review_status.py +++ b/scripts/ci/emit_review_status.py @@ -18,8 +18,8 @@ The ``source`` field is the workflow name that declared the status; the assembler uses it to exclude the corresponding job from the synthesized ❌ Error list (the job already has its own status section). -The array can contain 0, 1, or 2 results — one per lane that ran -(``ci_review``, ``mcp_catalog``). When the ``ci-reviewed`` label is +The array can contain 0 to 3 results — one per lane that ran +(``ci_review``, ``mcp_catalog``, ``supply_chain``). When the ``ci-reviewed`` label is present, the kind is ``info``; when missing, it's ``action_required`` with the verification checklist. """ @@ -44,6 +44,7 @@ SOURCE = "review-label-gate" def build_results( ci_review: bool, mcp_catalog: bool, + supply_chain: bool, label_present: bool, ) -> list[dict]: """Build the list of result objects for this source.""" @@ -99,16 +100,28 @@ def build_results( ), }) + if supply_chain and not label_present: + results.append({ + "kind": "action_required", + "title": "Critical supply chain risk", + "summary": "Critical supply chain risk patterns were detected in this PR.", + "how_to_fix": ( + "Review the flagged code carefully. If it is intentional, add the " + "`ci-reviewed` label to confirm maintainer review." + ), + }) + return results def build_statuses( ci_review: bool, mcp_catalog: bool, + supply_chain: bool, label_present: bool, ) -> list[dict]: """Build the full review_status array (one entry with a results list).""" - results = build_results(ci_review, mcp_catalog, label_present) + results = build_results(ci_review, mcp_catalog, supply_chain, label_present) if not results: return [] return [{"source": SOURCE, "results": results}] @@ -120,13 +133,17 @@ def main() -> int: help="Whether CI-sensitive files changed.") parser.add_argument("--mcp-catalog", action="store_true", help="Whether the MCP catalog / installer changed.") + parser.add_argument("--supply-chain", action="store_true", + help="Whether the critical supply-chain scanner found a risk.") parser.add_argument("--label-present", action="store_true", help="Whether the ci-reviewed label is present.") parser.add_argument("--output", default="-", help="Output file ('-' for stdout, or a GITHUB_OUTPUT path).") args = parser.parse_args() - statuses = build_statuses(args.ci_review, args.mcp_catalog, args.label_present) + statuses = build_statuses( + args.ci_review, args.mcp_catalog, args.supply_chain, args.label_present + ) json_str = json.dumps(statuses) if args.output == "-":