1- # This workflow posts to Slack when a PR is labeled with "notify-slack".
2- # It notifies reviewers based on each reviewer's local-time window from reviewers.json.
3- # It also prevents multiple Slack notifications for the same PR using a hidden PR comment marker.
4- #
5- # SECURITY NOTE:
6- # - This workflow uses pull_request_target so it can comment on PRs from forks.
7- # - Do NOT checkout the PR head in this workflow. This file intentionally does not use actions/checkout.
8- # "Notify once" semantics: we create a marker comment FIRST (lock). If we can't write the marker,
9- # we refuse to notify to avoid duplicate Slack notifications.
10-
11- name : Ready for Review Slack Notification
12-
13- ' on ' :
14- pull_request_target :
1+ # This workflow will post to Slack when a PR is labeled with "notify-slack".
2+ # It will notify the appropriate reviewers based on the current UTC hour.
3+ # It also ensures that Slack is not notified multiple times for the same PR.
4+ # The reviewers are defined in a Base64 encoded JSON file stored in a GitHub secret.
5+ # After notifying, it records the notification in the PR comments and securely deletes the reviewers file.
6+ # The workflow is designed to be efficient and secure, ensuring that sensitive information is handled properly.
7+
8+ name : Ready for Review
9+
10+ on :
11+ pull_request :
1512 types : [labeled]
1613
1714permissions :
18- issues : write
19- pull-requests : read
15+ pull-requests : write
2016
2117concurrency :
22- group : readyforreviewci-pr- ${{ github.event.pull_request.number }}
18+ group : readyforreviewci-${{ github.ref }}
2319 cancel-in-progress : true
2420
2521jobs :
@@ -28,141 +24,68 @@ jobs:
2824 runs-on : ubuntu-latest
2925
3026 steps :
31- # SECURITY: Intentionally no checkout. Do NOT checkout PR head in pull_request_target workflows.
32-
33- - name : Decode reviewers.json secret
34- env :
35- REVIEWERS_JSON_B64 : ${{ secrets.REVIEWER_CONFIG_B64 }}
36- run : |
37- echo "::group::Decode reviewer config"
38- if [ -z "${REVIEWERS_JSON_B64:-}" ]; then
39- echo "::warning::REVIEWER_CONFIG_B64 secret is empty or not set; no reviewers will be selected."
40- echo "{}" > reviewers.json
41- else
42- echo "$REVIEWERS_JSON_B64" | base64 --decode > reviewers.json || {
43- echo "::warning::Failed to decode REVIEWER_CONFIG_B64; no reviewers will be selected."
44- echo "{}" > reviewers.json
45- }
46- fi
47- echo "::endgroup::"
48-
49- - name : Check if Slack was already notified (marker comment)
27+ - name : Check for prior Slack notification comment
5028 id : check
51- env :
52- GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
53- MARKER : " <!-- slack-notified -->"
5429 run : |
55- echo "::group::Check marker comment"
56- COMMENTS_JSON="$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" " https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments")"
30+ COMMENTS=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
31+ https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments)
5732
58- COMMENT_ID="$(echo "$COMMENTS_JSON" | jq -r --arg m "$MARKER" '
59- [.[] | select(.body // "" | contains($m)) | .id][0] // ""')"
33+ COMMENT_ID=$(echo "$COMMENTS" | jq '.[] | select(.body | contains("<!-- slack-notified -->")) | .id')
6034
6135 if [ -n "$COMMENT_ID" ]; then
62- echo "notified=true" >> "$GITHUB_OUTPUT "
63- echo "comment_id=$COMMENT_ID " >> " $GITHUB_OUTPUT"
64- echo "::notice::Found marker comment id: $COMMENT_ID"
36+ echo "Slack already notified. "
37+ echo "notified=true " >> $GITHUB_OUTPUT
38+ echo "comment_id= $COMMENT_ID" >> $GITHUB_OUTPUT
6539 else
66- echo "notified=false" >> "$GITHUB_OUTPUT"
67- echo "comment_id=" >> "$GITHUB_OUTPUT"
68- echo "::notice::No marker comment found."
40+ echo "notified=false" >> $GITHUB_OUTPUT
6941 fi
70- echo "::endgroup::"
7142
72- - name : Acquire notify lock (create marker comment if missing)
43+ - name : Exit if already notified
44+ if : steps.check.outputs.notified == 'true'
45+ continue-on-error : true
46+ run : echo "Slack already notified — skipping remaining steps."
47+
48+ - name : Decode reviewer config (Base64)
7349 if : steps.check.outputs.notified == 'false'
74- id : lock
75- env :
76- GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
77- MARKER : " <!-- slack-notified -->"
50+ id : config
7851 run : |
79- echo "::group::Acquire notify lock"
80- TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
81- BODY=$'🟡 Slack notification locked at '"$TIMESTAMP"$'\n\n'"$MARKER"
82-
83- RESP="$(curl -fsSL -X POST -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" -d "$(jq -nc --arg body "$BODY" '{body: $body}')" "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments")"
84-
85- COMMENT_ID="$(echo "$RESP" | jq -r '.id // ""')"
86- if [ -z "$COMMENT_ID" ]; then
87- echo "::error::Failed to create marker comment; refusing to notify to avoid duplicates."
88- exit 1
89- fi
90-
91- echo "comment_id=$COMMENT_ID" >> "$GITHUB_OUTPUT"
92- echo "::notice::Notify lock acquired with comment id: $COMMENT_ID"
93- echo "::endgroup::"
52+ echo "${{ secrets.REVIEWER_CONFIG_B64 }}" | base64 -d > reviewers.json
9453
9554 - name : Choose Reviewers From Config
96- if : steps.lock .outputs.comment_id != ' '
55+ if : steps.check .outputs.notified == 'false '
9756 id : pick
9857 run : |
99- echo "::group::Select reviewers"
100- to_minutes () {
101- local t="$1"
102- if [[ "$t" == *:* ]]; then
103- local hh="${t%%:*}"
104- local mm="${t##*:}"
105- echo $((10#$hh * 60 + 10#$mm))
106- else
107- # Back-compat: integer hour => HH:00
108- echo $((10#$t * 60))
109- fi
110- }
111-
112- now_in_tz () {
113- local tz="$1"
114- local hh mm day
115- hh=$(TZ="$tz" date +%H)
116- mm=$(TZ="$tz" date +%M)
117- day=$(TZ="$tz" date +%a | tr '[:upper:]' '[:lower:]')
118- echo "$((10#$hh * 60 + 10#$mm)) $day $hh $mm"
119- }
58+ HOUR=$(date -u +'%H')
59+ DAY=$(date -u +%a | tr '[:upper:]' '[:lower:]')
60+ echo "UTC Hour: $HOUR | Day: $DAY"
12061
12162 REVIEWERS=""
12263
123- for reviewer in $(jq -r 'keys[]' reviewers.json 2>/dev/null || true); do
124- START_RAW=$(jq -r "."$reviewer".start" reviewers.json 2>/dev/null || echo "")
125- END_RAW=$(jq -r "."$reviewer".end" reviewers.json 2>/dev/null || echo "")
126- SLACK_ID=$(jq -r "."$reviewer".slack_id" reviewers.json 2>/dev/null || echo "")
127- TZID=$(jq -r "."$reviewer".tz // "UTC"" reviewers.json 2>/dev/null || echo "UTC")
128-
129- if [ -z "$START_RAW" ] || [ -z "$END_RAW" ] || [ -z "$SLACK_ID" ]; then
130- echo "::warning::Reviewer '$reviewer' missing required fields (start/end/slack_id); skipping."
131- continue
132- fi
133-
134- read -r NOW_MIN DAY HH MM <<< "$(now_in_tz "$TZID")"
135- echo "Reviewer: $reviewer | TZ: $TZID | Local: ${HH}:${MM} | Day: $DAY"
64+ for reviewer in $(jq -r 'keys[]' reviewers.json); do
65+ START=$(jq -r ".\"$reviewer\".start" reviewers.json)
66+ END=$(jq -r ".\"$reviewer\".end" reviewers.json)
67+ SLACK_ID=$(jq -r ".\"$reviewer\".slack_id" reviewers.json)
13668
137- if jq -e "."$reviewer".days" reviewers.json > /dev/null 2>&1 ; then
138- DAYS=$(jq -r "."$reviewer".days[]" reviewers.json 2>/dev/null | tr '\n' ' ')
69+ if jq -e ".\ "$reviewer\ ".days" reviewers.json > /dev/null; then
70+ DAYS=$(jq -r ".\ "$reviewer\ ".days[]" reviewers.json | tr '\n' ' ')
13971 [[ "$DAYS" != *"$DAY"* ]] && continue
14072 fi
14173
142- START_MIN=$(to_minutes "$START_RAW")
143- END_MIN=$(to_minutes "$END_RAW")
144-
145- if [ "$NOW_MIN" -ge "$START_MIN" ] && [ "$NOW_MIN" -lt "$END_MIN" ]; then
74+ if [ "$HOUR" -ge "$START" ] && [ "$HOUR" -lt "$END" ]; then
14675 REVIEWERS+="<@$SLACK_ID> "
14776 fi
14877 done
14978
15079 if [ -z "$REVIEWERS" ]; then
15180 REVIEWERS="<!here> _(No reviewers available — notifying team)_"
152- echo "::notice::No reviewers matched availability window; using <!here>."
153- else
154- echo "::notice::Selected reviewers: $REVIEWERS"
15581 fi
15682
157- echo "reviewers=$REVIEWERS" >> "$GITHUB_OUTPUT"
158- echo "::endgroup::"
83+ echo "reviewers=$REVIEWERS" >> $GITHUB_OUTPUT
15984
16085 - name : Notify Slack
161- if : steps.lock.outputs.comment_id != ''
162- continue-on-error : true
86+ if : steps.check.outputs.notified == 'false'
1638716488 env :
165- REVIEWERS : ${{ steps.pick.outputs.reviewers }}
16689 SLACK_WEBHOOK : ${{ secrets.SLACK_PRIVATE_TEAM_WEBHOOK }}
16790 SLACK_USERNAME : " spectromate"
16891 SLACK_ICON_EMOJI : " :robot_panic:"
@@ -171,42 +94,43 @@ jobs:
17194 :review: *<${{ github.event.pull_request.html_url }}|${{ github.event.pull_request.title }}>* is ready for review!
17295 Pinging: ${{ steps.pick.outputs.reviewers }}
17396
174- - name : Finalize marker comment
175- if : steps.lock.outputs.comment_id != ''
176- continue-on-error : true
177- env :
178- GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
179- MARKER : " <!-- slack-notified -->"
97+ - name : Create or Update Slack comment manually
98+ if : steps.check.outputs.notified == 'false'
18099 run : |
181- echo "::group::Finalize marker comment"
182- TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
183- BODY=$'✅ Slack notification attempted at '"$TIMESTAMP"$'\n\n'"$MARKER"
184- COMMENT_ID="${{ steps.lock.outputs.comment_id }}"
185-
186- curl -fsSL -X PATCH -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" -d "$(jq -nc --arg body "$BODY" '{body: $body}')" "https://api.github.com/repos/${{ github.repository }}/issues/comments/$COMMENT_ID" > /dev/null || echo "::warning::Failed to finalize marker comment ($COMMENT_ID)"
187- echo "::endgroup::"
100+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
101+ BODY="✅ Slack reviewers notified at $TIMESTAMP \n <!-- slack-notified -->"
102+
103+ if [ -n "${{ steps.check.outputs.comment_id }}" ]; then
104+ echo "Updating existing comment..."
105+ curl -s -X PATCH -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
106+ -H "Accept: application/vnd.github.v3+json" \
107+ -d "$(jq -nc --arg body "$BODY" '{body: $body}')" \
108+ https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ steps.check.outputs.comment_id }}
109+ else
110+ echo "Creating new comment..."
111+ curl -s -X POST -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
112+ -H "Accept: application/vnd.github.v3+json" \
113+ -d "$(jq -nc --arg body "$BODY" '{body: $body}')" \
114+ https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments
115+ fi
188116
189117 - name : Securely overwrite and delete reviewers.json
190118 if : always()
191119 run : |
192- echo "::group::Cleanup"
193120 if [ -f reviewers.json ]; then
194121 echo "Shredding reviewers.json"
195- dd if=/dev/urandom of=reviewers.json bs=1 count=$(stat -c%s reviewers.json) conv=notrunc status=none || true
196- rm -f reviewers.json || true
197- echo "Cleanup complete."
122+ dd if=/dev/urandom of=reviewers.json bs=1 count=$(stat -c%s reviewers.json) conv=notrunc status=none
123+ rm -f reviewers.json
124+ echo "Secure deletion complete."
198125 else
199126 echo "No reviewers.json file to delete."
200127 fi
201- echo "::endgroup::"
202128
203129 - name : Final Job Status Summary
204130 if : always()
205131 run : |
206132 if [ "${{ steps.check.outputs.notified }}" == "true" ]; then
207- echo "✅ Job completed: Slack was already notified (marker comment exists)."
208- elif [ -n "${{ steps.lock.outputs.comment_id }}" ]; then
209- echo "✅ Job completed: Notify lock acquired; Slack notification attempted."
133+ echo "✅ Job completed: Slack was already notified (comment updated if needed)."
210134 else
211- echo "ℹ️ Job completed: No lock acquired (likely already notified or skipped) ."
135+ echo "✅ Job completed: Slack notification was posted successfully ."
212136 fi
0 commit comments