From ea9af78a041b3f9ddc23251e204825be0708fc5d Mon Sep 17 00:00:00 2001 From: jth-nw Date: Tue, 4 Aug 2026 12:27:20 -0500 Subject: [PATCH 1/7] Add Slack notifications for docs-gh channel Posts to #docs-gh via SLACK_WEBHOOK_URL whenever the dev->main sync succeeds/fails/conflicts, a PR is created/commented/review-requested, or an issue is created/commented on. CODEOWNERS teams are resolved via CODEOWNERS (PRs) or label-codeowners.json (issues) and mapped to Slack member IDs through the new .github/team-slack-map.json. Co-Authored-By: Claude Sonnet 5 --- .github/label-codeowners.json | 1 + .github/team-slack-map.json | 29 +++++++ .github/workflows/slack-notify-issue.yml | 95 +++++++++++++++++++++++ .github/workflows/slack-notify-pr.yml | 73 ++++++++++++++++++ .github/workflows/sync-dev-to-main.yml | 22 ++++++ scripts/notify-slack.mjs | 96 ++++++++++++++++++++++++ scripts/resolve-codeowners-teams.mjs | 72 ++++++++++++++++++ 7 files changed, 388 insertions(+) create mode 100644 .github/team-slack-map.json create mode 100644 .github/workflows/slack-notify-issue.yml create mode 100644 .github/workflows/slack-notify-pr.yml create mode 100644 scripts/notify-slack.mjs create mode 100644 scripts/resolve-codeowners-teams.mjs diff --git a/.github/label-codeowners.json b/.github/label-codeowners.json index dd0f72f3cb..e11800a119 100644 --- a/.github/label-codeowners.json +++ b/.github/label-codeowners.json @@ -8,6 +8,7 @@ "data-classification": "@netwrix/dataclassification-docs", "directory-manager": "@netwrix/directorymanager-docs", "endpoint-policy-manager": "@netwrix/endpointpolicymanager-docs", + "policypak": "@netwrix/endpointpolicymanager-docs", "endpoint-protector": "@netwrix/endpointprotector-docs", "identity-manager": "@netwrix/identitymanager-docs", "identity-recovery": "@netwrix/recoveryforactivedirectory-docs", diff --git a/.github/team-slack-map.json b/.github/team-slack-map.json new file mode 100644 index 0000000000..dd7ba6833b --- /dev/null +++ b/.github/team-slack-map.json @@ -0,0 +1,29 @@ +{ + "_comment": "Maps each CODEOWNERS/label-codeowners team to the Slack member IDs to @-mention in #docs-gh. Find a member ID via their Slack profile -> \"...\" menu -> Copy member ID. Empty arrays post without an @-mention until filled in.", + "@netwrix/1secure-docs": [], + "@netwrix/accessanalyzer-docs": [], + "@netwrix/accessinformationcenter-docs": [], + "@netwrix/activitymonitor-docs": [], + "@netwrix/auditor-docs": [], + "@netwrix/changetracker-docs": [], + "@netwrix/dataclassification-docs": [], + "@netwrix/directorymanager-docs": [], + "@netwrix/endpointpolicymanager-docs": [], + "@netwrix/endpointprotector-docs": [], + "@netwrix/identitymanager-docs": [], + "@netwrix/passwordpolicyenforcer-docs": [], + "@netwrix/passwordreset-docs": [], + "@netwrix/passwordsecure-docs": [], + "@netwrix/pingcastle-docs": [], + "@netwrix/platgovnetsuite-docs": [], + "@netwrix/platgovnetsuiteflashlight-docs": [], + "@netwrix/platgovsalesforce-docs": [], + "@netwrix/platgovsalesforceflashlight-docs": [], + "@netwrix/privilegesecure-docs": [], + "@netwrix/privilegesecurediscovery-docs": [], + "@netwrix/recoveryforactivedirectory-docs": [], + "@netwrix/threatmanager-docs": [], + "@netwrix/threatprevention-docs": [], + "@netwrix/kb-docs": [], + "@netwrix/training-docs": [] +} diff --git a/.github/workflows/slack-notify-issue.yml b/.github/workflows/slack-notify-issue.yml new file mode 100644 index 0000000000..8d5d8e30ca --- /dev/null +++ b/.github/workflows/slack-notify-issue.yml @@ -0,0 +1,95 @@ +name: Slack Notify Issue + +# Posts a Slack message to #docs-gh whenever an Issue is created or commented on, +# @-mentioning the relevant CODEOWNERS team members. + +on: + issues: + types: [opened] + issue_comment: + types: [created] + +concurrency: + group: slack-notify-issue-${{ github.event.issue.number }} + cancel-in-progress: false + +permissions: + contents: read + issues: read + +jobs: + notify: + if: ${{ !github.event.issue.pull_request }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + .github + scripts + sparse-checkout-cone-mode: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + + - name: Determine codeowner teams + id: codeowners + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_TITLE: ${{ github.event.issue.title }} + run: | + # Fetch current labels from the issue + LABELS=$(gh issue view ${{ github.event.issue.number }} --repo ${{ github.repository }} --json labels --jq '.labels[].name') + + if [ -z "$LABELS" ]; then + echo "No labels found — skipping Slack notification" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Skip for KB Operations tracking issues (any kb/ label) + if echo "$LABELS" | grep -q "^kb/"; then + echo "Issue has kb/ label — skipping Slack notification" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Skip for Admin Support PR review tracking issues + if echo "$ISSUE_TITLE" | grep -q "^Admin: PR review"; then + echo "Admin Support tracking issue — skipping Slack notification" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Read the mapping file + MAPPING=$(cat .github/label-codeowners.json) + + # Collect matched teams (deduplicated), comma-joined + TEAMS="" + while IFS= read -r label; do + team=$(echo "$MAPPING" | jq -r --arg l "$label" '.[$l] // empty') + if [ -n "$team" ]; then + if ! echo "$TEAMS" | grep -qF "$team"; then + TEAMS="${TEAMS:+$TEAMS,}$team" + fi + fi + done <<< "$LABELS" + + echo "teams=$TEAMS" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Send Slack notification + if: steps.codeowners.outputs.skip != 'true' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + if [ "${{ github.event_name }}" = "issues" ]; then + MESSAGE="Issue created: ${{ github.event.issue.html_url }}" + else + MESSAGE="Comment on Issue #${{ github.event.issue.number }}: ${{ github.event.comment.html_url }}" + fi + + node scripts/notify-slack.mjs --message "$MESSAGE" --teams "${{ steps.codeowners.outputs.teams }}" || echo "Failed to send Slack notification" diff --git a/.github/workflows/slack-notify-pr.yml b/.github/workflows/slack-notify-pr.yml new file mode 100644 index 0000000000..d800ab8848 --- /dev/null +++ b/.github/workflows/slack-notify-pr.yml @@ -0,0 +1,73 @@ +name: Slack notify PR activity + +# Posts a Slack message to #docs-gh whenever a PR is created, has a review +# requested, or is commented on — @-mentioning the CODEOWNERS team(s) that +# own the changed files. + +on: + pull_request: + types: [opened, review_requested] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: slack-notify-pr-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false + +jobs: + notify: + # issue_comment fires for both issue comments and PR comments — only + # continue here when the comment is on a PR. Plain issue comments are + # handled by the separate issue-intake workflow. + if: github.event_name == 'pull_request' || github.event.issue.pull_request + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + + - name: Determine PR number + id: pr-info + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + else + echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve CODEOWNERS teams for changed files + id: resolve-teams + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + FILES=$(gh pr view ${{ steps.pr-info.outputs.number }} --repo ${{ github.repository }} --json files --jq '.files[].path') + TEAMS=$(node scripts/resolve-codeowners-teams.mjs $(echo "$FILES" | tr '\n' ' ')) + echo "teams=$TEAMS" >> "$GITHUB_OUTPUT" + + - name: Build message + id: build-message + run: | + # Build the notification text based on which event/action fired. + if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "opened" ]; then + MESSAGE="PR created: ${{ github.event.pull_request.html_url }}" + elif [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "review_requested" ]; then + MESSAGE="Review requested on PR #${{ steps.pr-info.outputs.number }}: ${{ github.event.pull_request.html_url }}" + else + MESSAGE="Comment on PR #${{ steps.pr-info.outputs.number }}: ${{ github.event.comment.html_url }}" + fi + echo "message=$MESSAGE" >> "$GITHUB_OUTPUT" + + - name: Send Slack notification + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + node scripts/notify-slack.mjs --message "${{ steps.build-message.outputs.message }}" --teams "${{ steps.resolve-teams.outputs.teams }}" || echo "Failed to send Slack notification" diff --git a/.github/workflows/sync-dev-to-main.yml b/.github/workflows/sync-dev-to-main.yml index c1d9b925ad..cef47f0907 100644 --- a/.github/workflows/sync-dev-to-main.yml +++ b/.github/workflows/sync-dev-to-main.yml @@ -21,6 +21,8 @@ env: TARGET_BRANCH: main # Teams webhook URL stored in GitHub Secrets for security TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} + # Slack webhook URL stored in GitHub Secrets for security + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} jobs: sync-branches: @@ -33,6 +35,11 @@ jobs: fetch-depth: 0 # Full history for merge token: ${{ secrets.SYNC_PAT }} + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + - name: Configure git run: | git config user.name "github-actions[bot]" @@ -200,6 +207,11 @@ jobs: }] }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" + - name: Send success notification to Slack + if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'false' + run: | + node scripts/notify-slack.mjs --message "✅ dev→main sync succeeded: ${{ steps.merge-details.outputs.commit_count }} commits merged (latest ${{ steps.merge-details.outputs.commit_sha }}). ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || echo "Failed to send Slack notification" + - name: Create GitHub issue for conflict if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -276,6 +288,11 @@ jobs: }] }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" + - name: Send conflict notification to Slack + if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'true' + run: | + node scripts/notify-slack.mjs --message "⚠️ dev→main sync blocked by merge conflict: ${{ steps.check-new-commits.outputs.commits_ahead }} commits waiting. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || echo "Failed to send Slack notification" + - name: Send skip notification to Teams if: steps.check-new-commits.outputs.has_new_commits == 'false' run: | @@ -292,6 +309,11 @@ jobs: }] }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" + - name: Send failure notification to Slack + if: failure() + run: | + node scripts/notify-slack.mjs --message "❌ dev→main sync failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || echo "Failed to send Slack notification" + - name: Workflow summary if: always() run: | diff --git a/scripts/notify-slack.mjs b/scripts/notify-slack.mjs new file mode 100644 index 0000000000..e48dce3000 --- /dev/null +++ b/scripts/notify-slack.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +/** + * Post a Slack notification to #docs-gh via an Incoming Webhook. + * + * Usage: + * node scripts/notify-slack.mjs --message "PR created: https://github.com/netwrix/docs/pull/123" --teams "@netwrix/changetracker-docs,@netwrix/kb-docs" + * node scripts/notify-slack.mjs --message "dev merged to main" + * + * Environment variables: + * SLACK_WEBHOOK_URL Incoming Webhook URL (required) + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const TEAM_MAP_PATH = path.join(PROJECT_ROOT, '.github', 'team-slack-map.json'); + +function parseArgs(argv) { + const args = { message: null, teams: null }; + + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--message') { + args.message = argv[++i]; + } else if (argv[i] === '--teams') { + args.teams = argv[++i]; + } + } + + return args; +} + +function resolveMentions(teamsArg, teamMap) { + if (!teamsArg) return []; + + const teams = teamsArg.split(',').map(t => t.trim()).filter(Boolean); + const ids = new Set(); + + for (const team of teams) { + if (team === '_comment') continue; + + const teamIds = teamMap[team]; + + if (!teamIds || !Array.isArray(teamIds) || teamIds.length === 0) { + console.error(`Warning: no Slack member IDs found for team "${team}"`); + continue; + } + + teamIds.forEach(id => ids.add(id)); + } + + return [...ids].map(id => `<@${id}>`); +} + +async function main() { + const { message, teams } = parseArgs(process.argv.slice(2)); + + if (!message) { + console.error('Error: --message is required'); + process.exit(1); + } + + const webhookUrl = process.env.SLACK_WEBHOOK_URL; + + if (!webhookUrl) { + console.error('Error: SLACK_WEBHOOK_URL environment variable is not set'); + process.exit(1); + } + + const teamMap = JSON.parse(fs.readFileSync(TEAM_MAP_PATH, 'utf8')); + const mentions = resolveMentions(teams, teamMap); + const text = mentions.length > 0 ? `${message} ${mentions.join(' ')}` : message; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }) + }); + + if (!response.ok) { + const body = await response.text(); + console.error(`Error: Slack webhook returned ${response.status}: ${body}`); + process.exit(1); + } + + console.log('Slack notification sent'); +} + +main().catch(err => { + console.error(`Error: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/resolve-codeowners-teams.mjs b/scripts/resolve-codeowners-teams.mjs new file mode 100644 index 0000000000..07fb13b063 --- /dev/null +++ b/scripts/resolve-codeowners-teams.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +/** + * Resolve CODEOWNERS teams for a set of changed files + * + * Usage: + * node scripts/resolve-codeowners-teams.mjs ... + * + * Prints a comma-joined, deduped list of matching team handles to stdout + * (no trailing newline handling required by caller). Prints nothing if + * no files match any pattern. + */ + +import fs from 'fs'; +import path from 'path'; + +const PROJECT_ROOT = process.cwd(); +const CODEOWNERS_PATH = path.join(PROJECT_ROOT, '.github', 'CODEOWNERS'); + +function parseCodeowners(content) { + const rules = []; + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const parts = line.split(/\s+/); + const pattern = parts[0]; + const owners = parts.slice(1); + if (owners.length === 0) continue; + + const anchored = pattern.startsWith('/') ? pattern.slice(1) : pattern; + rules.push({ pattern: anchored, isDir: anchored.endsWith('/'), owners }); + } + + return rules; +} + +function matchFile(rules, filePath) { + let matchedOwners = null; + + for (const rule of rules) { + if (rule.isDir) { + if (filePath === rule.pattern.slice(0, -1) || filePath.startsWith(rule.pattern)) { + matchedOwners = rule.owners; + } + } else if (filePath === rule.pattern) { + matchedOwners = rule.owners; + } + } + + return matchedOwners; +} + +function main() { + const files = process.argv.slice(2); + const content = fs.readFileSync(CODEOWNERS_PATH, 'utf8'); + const rules = parseCodeowners(content); + + const teams = []; + for (const file of files) { + const owners = matchFile(rules, file); + if (!owners) continue; + for (const owner of owners) { + if (!teams.includes(owner)) teams.push(owner); + } + } + + process.stdout.write(teams.join(',')); +} + +main(); From 71c4a1ede985865cd4f1c4694a86fc6917e3a9b6 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Tue, 4 Aug 2026 12:57:31 -0500 Subject: [PATCH 2/7] fix(ci): avoid script injection via CODEOWNERS teams output in Slack notify workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged steps.resolve-teams.outputs.teams being interpolated directly into a run: block in slack-notify-pr.yml — a PR that edits .github/CODEOWNERS could inject shell syntax that executes with the job's secrets in scope. Pass the dynamic values through env: instead. Applied the same defensive pattern to slack-notify-issue.yml. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/slack-notify-issue.yml | 3 ++- .github/workflows/slack-notify-pr.yml | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/slack-notify-issue.yml b/.github/workflows/slack-notify-issue.yml index 8d5d8e30ca..7dd13f7162 100644 --- a/.github/workflows/slack-notify-issue.yml +++ b/.github/workflows/slack-notify-issue.yml @@ -85,6 +85,7 @@ jobs: if: steps.codeowners.outputs.skip != 'true' env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + NOTIFY_TEAMS: ${{ steps.codeowners.outputs.teams }} run: | if [ "${{ github.event_name }}" = "issues" ]; then MESSAGE="Issue created: ${{ github.event.issue.html_url }}" @@ -92,4 +93,4 @@ jobs: MESSAGE="Comment on Issue #${{ github.event.issue.number }}: ${{ github.event.comment.html_url }}" fi - node scripts/notify-slack.mjs --message "$MESSAGE" --teams "${{ steps.codeowners.outputs.teams }}" || echo "Failed to send Slack notification" + node scripts/notify-slack.mjs --message "$MESSAGE" --teams "$NOTIFY_TEAMS" || echo "Failed to send Slack notification" diff --git a/.github/workflows/slack-notify-pr.yml b/.github/workflows/slack-notify-pr.yml index d800ab8848..1b30d5c849 100644 --- a/.github/workflows/slack-notify-pr.yml +++ b/.github/workflows/slack-notify-pr.yml @@ -69,5 +69,7 @@ jobs: - name: Send Slack notification env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + NOTIFY_MESSAGE: ${{ steps.build-message.outputs.message }} + NOTIFY_TEAMS: ${{ steps.resolve-teams.outputs.teams }} run: | - node scripts/notify-slack.mjs --message "${{ steps.build-message.outputs.message }}" --teams "${{ steps.resolve-teams.outputs.teams }}" || echo "Failed to send Slack notification" + node scripts/notify-slack.mjs --message "$NOTIFY_MESSAGE" --teams "$NOTIFY_TEAMS" || echo "Failed to send Slack notification" From da042c34fbbb6eca598340392a2b6ba893946399 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Tue, 4 Aug 2026 16:28:52 -0500 Subject: [PATCH 3/7] fix(ci): address low-severity Slack notification review findings - resolve-codeowners-teams.mjs: match non-wildcard CODEOWNERS patterns recursively regardless of trailing slash, fixing missed matches for /static/images/training and /static/images/customer-portal - slack-notify-issue.yml: don't skip notification when labels haven't landed yet (auto-labeling race); post without a team mention instead - slack-notify-pr.yml: skip the Slack step cleanly on fork PRs, which lack SLACK_WEBHOOK_URL under pull_request by design Co-Authored-By: Claude Sonnet 5 --- .github/workflows/slack-notify-issue.yml | 10 +++------- .github/workflows/slack-notify-pr.yml | 5 +++++ scripts/resolve-codeowners-teams.mjs | 11 +++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/slack-notify-issue.yml b/.github/workflows/slack-notify-issue.yml index 7dd13f7162..2412af792d 100644 --- a/.github/workflows/slack-notify-issue.yml +++ b/.github/workflows/slack-notify-issue.yml @@ -41,15 +41,11 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_TITLE: ${{ github.event.issue.title }} run: | - # Fetch current labels from the issue + # Fetch current labels from the issue. Labels may not be applied yet + # (auto-labeling runs asynchronously), so an empty result here just + # means no team mention — it does not skip the notification itself. LABELS=$(gh issue view ${{ github.event.issue.number }} --repo ${{ github.repository }} --json labels --jq '.labels[].name') - if [ -z "$LABELS" ]; then - echo "No labels found — skipping Slack notification" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - # Skip for KB Operations tracking issues (any kb/ label) if echo "$LABELS" | grep -q "^kb/"; then echo "Issue has kb/ label — skipping Slack notification" diff --git a/.github/workflows/slack-notify-pr.yml b/.github/workflows/slack-notify-pr.yml index 1b30d5c849..59091fda1f 100644 --- a/.github/workflows/slack-notify-pr.yml +++ b/.github/workflows/slack-notify-pr.yml @@ -67,6 +67,11 @@ jobs: echo "message=$MESSAGE" >> "$GITHUB_OUTPUT" - name: Send Slack notification + # Fork PRs don't have access to repo secrets on `pull_request` (by + # design, to prevent secret exfiltration) — skip cleanly instead of + # attempting and failing. Comments on a PR (issue_comment) aren't + # fork code execution, so secrets are available there regardless. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} NOTIFY_MESSAGE: ${{ steps.build-message.outputs.message }} diff --git a/scripts/resolve-codeowners-teams.mjs b/scripts/resolve-codeowners-teams.mjs index 07fb13b063..4765cd325e 100644 --- a/scripts/resolve-codeowners-teams.mjs +++ b/scripts/resolve-codeowners-teams.mjs @@ -30,7 +30,8 @@ function parseCodeowners(content) { if (owners.length === 0) continue; const anchored = pattern.startsWith('/') ? pattern.slice(1) : pattern; - rules.push({ pattern: anchored, isDir: anchored.endsWith('/'), owners }); + const stripped = anchored.endsWith('/') ? anchored.slice(0, -1) : anchored; + rules.push({ pattern: stripped, owners }); } return rules; @@ -40,11 +41,9 @@ function matchFile(rules, filePath) { let matchedOwners = null; for (const rule of rules) { - if (rule.isDir) { - if (filePath === rule.pattern.slice(0, -1) || filePath.startsWith(rule.pattern)) { - matchedOwners = rule.owners; - } - } else if (filePath === rule.pattern) { + // CODEOWNERS treats a non-wildcard pattern as matching both the exact + // path and, recursively, everything under it — trailing slash or not. + if (filePath === rule.pattern || filePath.startsWith(`${rule.pattern}/`)) { matchedOwners = rule.owners; } } From 66c6eac7975ecb8acabd3b1b004ba42800d7dd13 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Tue, 4 Aug 2026 16:40:06 -0500 Subject: [PATCH 4/7] fix(ci): filter bot comments and pin base ref in Slack notify workflows - Exclude bot-authored issue_comment events from both Slack workflows to stop automated tooling comments (Vale autofix, Claude reviewers, etc.) from generating Slack pings - slack-notify-pr.yml: checkout the PR base sha instead of the head/merge ref so a fork PR can't get its own modified resolver script or CODEOWNERS executed with repo permissions - Document the resolver's lack of glob/wildcard support in CODEOWNERS matching Co-Authored-By: Claude Sonnet 5 --- .github/workflows/slack-notify-issue.yml | 4 +++- .github/workflows/slack-notify-pr.yml | 10 ++++++++-- scripts/resolve-codeowners-teams.mjs | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/slack-notify-issue.yml b/.github/workflows/slack-notify-issue.yml index 2412af792d..4a64b24fbe 100644 --- a/.github/workflows/slack-notify-issue.yml +++ b/.github/workflows/slack-notify-issue.yml @@ -19,7 +19,9 @@ permissions: jobs: notify: - if: ${{ !github.event.issue.pull_request }} + # Bot-authored comments (Vale autofix, Claude reviewers, etc.) are + # excluded to avoid Slack spam from automated commentary. + if: ${{ !github.event.issue.pull_request && (github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot') }} runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/slack-notify-pr.yml b/.github/workflows/slack-notify-pr.yml index 59091fda1f..36d26ea40f 100644 --- a/.github/workflows/slack-notify-pr.yml +++ b/.github/workflows/slack-notify-pr.yml @@ -22,13 +22,19 @@ jobs: notify: # issue_comment fires for both issue comments and PR comments — only # continue here when the comment is on a PR. Plain issue comments are - # handled by the separate issue-intake workflow. - if: github.event_name == 'pull_request' || github.event.issue.pull_request + # handled by the separate issue-intake workflow. Bot-authored comments + # (Vale autofix, Claude reviewers, etc.) are excluded to avoid Slack + # spam from automated commentary. + if: (github.event_name == 'pull_request' || github.event.issue.pull_request) && (github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot') runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Pin to the base ref on `pull_request` so a fork PR can't get its + # own modified resolver script (or CODEOWNERS) executed here. + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/scripts/resolve-codeowners-teams.mjs b/scripts/resolve-codeowners-teams.mjs index 4765cd325e..700aee45de 100644 --- a/scripts/resolve-codeowners-teams.mjs +++ b/scripts/resolve-codeowners-teams.mjs @@ -37,6 +37,9 @@ function parseCodeowners(content) { return rules; } +// Only plain directory/file prefixes are supported — no glob support (`*`, +// `**`, `*.md`). Every active CODEOWNERS rule today is a plain prefix; a +// future wildcard rule would silently match nothing here. function matchFile(rules, filePath) { let matchedOwners = null; From 7aee6b3c5961f1dc81ed9ba6f465ae9ee0565830 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Tue, 4 Aug 2026 18:08:26 -0500 Subject: [PATCH 5/7] fix(ci): extend sync-branches build wait timeout to 45 minutes The 30-minute wait was too tight for build+deploy runs that take ~30 minutes on their own, causing sync-dev-to-main to time out even though the build eventually succeeded. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/sync-dev-to-main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-dev-to-main.yml b/.github/workflows/sync-dev-to-main.yml index cef47f0907..b2302e6929 100644 --- a/.github/workflows/sync-dev-to-main.yml +++ b/.github/workflows/sync-dev-to-main.yml @@ -69,7 +69,7 @@ jobs: run: | echo "⏳ Waiting for 'build-and-deploy.yml' workflow to complete on ${{ env.SOURCE_BRANCH }}..." - MAX_WAIT=1800 # 30 minutes max wait + MAX_WAIT=2700 # 45 minutes max wait WAIT_INTERVAL=30 # Check every 30 seconds ELAPSED=0 From 05994303e3d0a38718fa82b242bd2aaac5b00f88 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Tue, 4 Aug 2026 18:24:08 -0500 Subject: [PATCH 6/7] fix(ci): avoid word-splitting and align env-var pattern in Slack notify workflows - slack-notify-pr.yml: read changed-file paths into a bash array via mapfile instead of unquoted command substitution, so paths with spaces or glob characters aren't split/expanded before reaching the resolver script - slack-notify-issue.yml: pass issue/comment URLs through env vars instead of inline ${{ }} interpolation in the run: block, matching the pattern already used in slack-notify-pr.yml Co-Authored-By: Claude Sonnet 5 --- .github/workflows/slack-notify-issue.yml | 7 +++++-- .github/workflows/slack-notify-pr.yml | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/slack-notify-issue.yml b/.github/workflows/slack-notify-issue.yml index 4a64b24fbe..d462b16d28 100644 --- a/.github/workflows/slack-notify-issue.yml +++ b/.github/workflows/slack-notify-issue.yml @@ -84,11 +84,14 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} NOTIFY_TEAMS: ${{ steps.codeowners.outputs.teams }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + COMMENT_URL: ${{ github.event.comment.html_url }} run: | if [ "${{ github.event_name }}" = "issues" ]; then - MESSAGE="Issue created: ${{ github.event.issue.html_url }}" + MESSAGE="Issue created: $ISSUE_URL" else - MESSAGE="Comment on Issue #${{ github.event.issue.number }}: ${{ github.event.comment.html_url }}" + MESSAGE="Comment on Issue #$ISSUE_NUMBER: $COMMENT_URL" fi node scripts/notify-slack.mjs --message "$MESSAGE" --teams "$NOTIFY_TEAMS" || echo "Failed to send Slack notification" diff --git a/.github/workflows/slack-notify-pr.yml b/.github/workflows/slack-notify-pr.yml index 36d26ea40f..4c0adcb143 100644 --- a/.github/workflows/slack-notify-pr.yml +++ b/.github/workflows/slack-notify-pr.yml @@ -56,7 +56,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | FILES=$(gh pr view ${{ steps.pr-info.outputs.number }} --repo ${{ github.repository }} --json files --jq '.files[].path') - TEAMS=$(node scripts/resolve-codeowners-teams.mjs $(echo "$FILES" | tr '\n' ' ')) + mapfile -t FILE_ARRAY <<< "$FILES" + TEAMS=$(node scripts/resolve-codeowners-teams.mjs "${FILE_ARRAY[@]}") echo "teams=$TEAMS" >> "$GITHUB_OUTPUT" - name: Build message From c6d2bd06f315dfa58e14afa983f4bade5e734276 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Tue, 4 Aug 2026 18:43:03 -0500 Subject: [PATCH 7/7] feat(ci): notify Slack and tag codeowners on fork PRs Switch the PR-open/review-requested triggers to pull_request_target so the job has secrets and a write-scoped token for fork PRs too. Checkout stays pinned to the base ref and nothing reads or executes fork-supplied code, so this doesn't reintroduce the fork-secrets risk pull_request_target is normally flagged for. - Fork PRs now get the same Slack "PR created" notification (with CODEOWNERS mention) that same-repo PRs already got - Fork PRs additionally get a plain PR comment tagging the resolved CODEOWNERS teams, since fork contributors/reviewers may not be in Slack Co-Authored-By: Claude Sonnet 5 --- .github/workflows/slack-notify-pr.yml | 49 +++++++++++++++++++-------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/.github/workflows/slack-notify-pr.yml b/.github/workflows/slack-notify-pr.yml index 4c0adcb143..6e5385ec86 100644 --- a/.github/workflows/slack-notify-pr.yml +++ b/.github/workflows/slack-notify-pr.yml @@ -2,17 +2,25 @@ name: Slack notify PR activity # Posts a Slack message to #docs-gh whenever a PR is created, has a review # requested, or is commented on — @-mentioning the CODEOWNERS team(s) that -# own the changed files. +# own the changed files. Fork PRs also get a PR comment tagging codeowners, +# since they otherwise have no other reviewer visibility into who owns the +# changed area. on: - pull_request: + # pull_request_target (not pull_request) so this job has repo secrets and + # a write-scoped token even for fork PRs. SAFETY: this workflow must never + # check out or execute the PR's head content — only read PR metadata via + # the GitHub API (gh pr view) and run code from the pinned base ref below. + # Adding a checkout of the fork's head ref here would reintroduce the + # exact secret-exfiltration risk pull_request_target is meant to avoid. + pull_request_target: types: [opened, review_requested] issue_comment: types: [created] permissions: contents: read - pull-requests: read + pull-requests: write concurrency: group: slack-notify-pr-${{ github.event.pull_request.number || github.event.issue.number }} @@ -25,16 +33,16 @@ jobs: # handled by the separate issue-intake workflow. Bot-authored comments # (Vale autofix, Claude reviewers, etc.) are excluded to avoid Slack # spam from automated commentary. - if: (github.event_name == 'pull_request' || github.event.issue.pull_request) && (github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot') + if: (github.event_name == 'pull_request_target' || github.event.issue.pull_request) && (github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot') runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Pin to the base ref on `pull_request` so a fork PR can't get its - # own modified resolver script (or CODEOWNERS) executed here. - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} + # Always the base ref — see the SAFETY note above. Never point + # this at the PR head/merge ref. + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -44,7 +52,7 @@ jobs: - name: Determine PR number id: pr-info run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then + if [ "${{ github.event_name }}" = "pull_request_target" ]; then echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" else echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" @@ -64,21 +72,32 @@ jobs: id: build-message run: | # Build the notification text based on which event/action fired. - if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "opened" ]; then + if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.action }}" = "opened" ]; then MESSAGE="PR created: ${{ github.event.pull_request.html_url }}" - elif [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "review_requested" ]; then + elif [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.action }}" = "review_requested" ]; then MESSAGE="Review requested on PR #${{ steps.pr-info.outputs.number }}: ${{ github.event.pull_request.html_url }}" else MESSAGE="Comment on PR #${{ steps.pr-info.outputs.number }}: ${{ github.event.comment.html_url }}" fi echo "message=$MESSAGE" >> "$GITHUB_OUTPUT" + - name: Comment on fork PRs tagging codeowners + # Fork contributors and reviewers may not be in the Slack channel, + # so give them a visible, on-PR codeowners tag too. Only needed + # once, on open — review_requested/comments already notify Slack. + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'opened' && + github.event.pull_request.head.repo.full_name != github.repository && + steps.resolve-teams.outputs.teams != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NOTIFY_TEAMS: ${{ steps.resolve-teams.outputs.teams }} + run: | + MENTIONS=$(echo "$NOTIFY_TEAMS" | tr ',' ' ') + gh pr comment ${{ steps.pr-info.outputs.number }} --repo ${{ github.repository }} --body "Thanks for the contribution! Tagging codeowners for review: $MENTIONS" + - name: Send Slack notification - # Fork PRs don't have access to repo secrets on `pull_request` (by - # design, to prevent secret exfiltration) — skip cleanly instead of - # attempting and failing. Comments on a PR (issue_comment) aren't - # fork code execution, so secrets are available there regardless. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} NOTIFY_MESSAGE: ${{ steps.build-message.outputs.message }}