Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/scripts/classify.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env node
// Risk classifier for PR auto-merge gating.
//
// Reads .github/risk-paths.yml from cwd (the caller repo's checkout), reads
// the list of changed files from stdin (one path per line), and prints the
// highest-priority risk class to stdout.
//
// Priority (high → low): blocked > sensitive > standard > safe_test > safe_deps > safe_config > trivial
// `standard` is the implicit fallback for any file that doesn't match a
// known class — this is intentional: unknown paths default to the most
// strict non-blocking class so the auto-merge gate stays safe.
//
// CLI:
// echo "<file paths, one per line>" | node classify.mjs
//
// Exit codes:
// 0 — printed a class name on stdout
// 1 — fatal error (missing rules file, parse error, etc.)
//
// Used by topcoder1/ci-workflows/.github/workflows/pr-classify.yml.

import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
import { minimatch } from 'minimatch';

const RULES_PATH = '.github/risk-paths.yml';
const PRIORITY = [
'blocked',
'sensitive',
'standard',
'safe_test',
'safe_deps',
'safe_config',
'trivial'
];
// Classes we test patterns against. `standard` is not in this list — it's
// the fallback for any file that matches NO pattern in any class.
const PATTERN_CLASSES = ['blocked', 'sensitive', 'safe_test', 'safe_deps', 'safe_config', 'trivial'];

function fail(msg) {
process.stderr.write(`classify.mjs: ${msg}\n`);
process.exit(1);
}

let rules;
try {
rules = parse(readFileSync(RULES_PATH, 'utf8'));
} catch (e) {
fail(`failed to read ${RULES_PATH}: ${e.message}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: rules is not guarded against null

yaml.parse() returns null for an empty file or one that contains only comments. The try/catch above catches I/O and parse errors, but a valid-but-empty YAML file silently produces null. The first call to rules[cls] in classify() (line 66) then throws an uncaught TypeError: Cannot read properties of null (reading 'blocked'), which exits with code 1 and a stack trace instead of the clean fail() message.

The "Verify risk-paths.yml exists" workflow step only checks for file presence, so this path is reachable.

Suggested change
}
}
if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
fail(`${RULES_PATH} must be a non-empty YAML mapping (got ${rules === null ? 'null' : typeof rules})`);
}


const changedFiles = readFileSync(0, 'utf8')
.split('\n')
.map((s) => s.trim())
.filter(Boolean);

if (changedFiles.length === 0) {
// No changed files = nothing to classify. Default to standard so the
// caller doesn't choke on an empty PR (shouldn't happen on real PRs).
process.stdout.write('standard\n');
process.exit(0);
}

function classify(file) {
for (const cls of PATTERN_CLASSES) {
const patterns = rules[cls] || [];
for (const p of patterns) {
if (minimatch(file, p, { dot: true, matchBase: false })) return cls;
}
}
return 'standard';
}

const classes = new Set(changedFiles.map(classify));
const winner = PRIORITY.find((c) => classes.has(c)) || 'standard';
process.stdout.write(winner + '\n');
94 changes: 94 additions & 0 deletions .github/workflows/pr-classify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: PR Risk Classifier (reusable)

# Reads .github/risk-paths.yml from the caller repo's checkout, classifies
# the PR's changed files, and exposes the highest-priority class as both a
# PR label and a job output for downstream workflows to gate on.
#
# Priority (high → low):
# blocked > sensitive > standard > safe_test > safe_deps > safe_config > trivial
#
# Caller wires this in via `uses:` and consumes `outputs.risk_class`.
# See impl plan §4 for the design rationale.

on:
workflow_call:
outputs:
risk_class:
description: "Highest risk class for this PR (blocked|sensitive|standard|safe_test|safe_deps|safe_config|trivial)"
value: ${{ jobs.classify.outputs.class }}

jobs:
classify:
name: Classify PR Risk
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
outputs:
class: ${{ steps.compute.outputs.class }}
steps:
- name: Checkout caller repo
uses: actions/checkout@v5

- name: Verify risk-paths.yml exists
run: |
if [ ! -f .github/risk-paths.yml ]; then
echo "::error::.github/risk-paths.yml is missing — every repo using pr-classify.yml MUST have one. See: https://github.com/topcoder1/ci-workflows/blob/main/README.md#pr-classifier"
exit 1
fi

- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: '22'

- name: Fetch classifier script from this reusable's repo
env:
GH_TOKEN: ${{ github.token }}
run: |
# The caller checked out THEIR repo. We need our classify.mjs script,
# which lives in topcoder1/ci-workflows. Pull it via the GitHub API
# so we don't pollute the caller's working tree.
mkdir -p .github/scripts
gh api repos/topcoder1/ci-workflows/contents/.github/scripts/classify.mjs \
--jq '.content' | base64 -d > .github/scripts/classify.mjs

- name: Install classifier deps
run: npm install --no-save yaml@2 minimatch@10

- name: Compute highest-priority class
id: compute
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
changed=$(gh pr diff "$PR" --name-only)
class=$(echo "$changed" | node .github/scripts/classify.mjs)
echo "class=$class" >> "$GITHUB_OUTPUT"
echo "Computed risk class: $class"
echo "Changed files:"
echo "$changed" | sed 's/^/ /'

- name: Set risk label on PR
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
CLASS: ${{ steps.compute.outputs.class }}
run: |
# Remove any existing risk:* labels, add the current one.
existing=$(gh pr view "$PR" --json labels --jq '.labels[].name' | grep '^risk:' || true)
for L in $existing; do
if [ "$L" != "risk:$CLASS" ]; then
gh pr edit "$PR" --remove-label "$L" 2>/dev/null || true
fi
done
# Create the label if it doesn't exist (idempotent — gh ignores conflict).
gh label create "risk:$CLASS" --color "ededed" --description "Risk class: $CLASS" 2>/dev/null || true
gh pr edit "$PR" --add-label "risk:$CLASS"

- name: Block merge for `blocked` class
if: steps.compute.outputs.class == 'blocked'
run: |
echo "::error::This PR touches blocked paths (Dockerfile, compose, workflows, secrets, classifier itself). Auto-merge is refused; manual merge by a maintainer is required."
exit 1