Skip to content

Commit 53f668a

Browse files
matus-tomleinclaude
andcommitted
chore(ci): generate release notes without Claude (ST-482)
Wiz flagged the prepare-release workflow as a publicly exposed workflow with access to secrets that is vulnerable to script injection (ST-482). Claude Code should not run in public repos, so replace the two LLM calls with deterministic shell scripts and remove ANTHROPIC_API_KEY from the job. The PR body is now generated by scripts; changelogs remain owned by rush. Commits are classified by: 1. Conventional-commit prefix (feat/fix/perf/refactor; "!" or a BREAKING CHANGE marker promotes to breaking). 2. A leading imperative verb, for the many commits in this repo that predate conventional commits (adoption is currently well under 25%, so a prefix-only classifier would put most changes in one bucket). 3. Anything left over becomes "Enhancements". Chore commits (ci/docs/test/build/style, release automation) are skipped, matching the previous prompt behaviour and the existing release notes. Also move every workflow input out of inline ${{ }} interpolation and into env: vars referenced as "$VAR". Splicing an input directly into a run: block is the script-injection sink Wiz reported; these workflows are workflow_dispatch-only, so the input is not attacker-controlled via pull requests, but the pattern is fixed regardless. Verified by regenerating past releases from real history and diffing against the shipped release notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2dd31f6 commit 53f668a

4 files changed

Lines changed: 270 additions & 76 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Classify release commits into categories for CHANGELOG entries and PR bodies.
4+
#
5+
# Reads annotated commit lines on stdin, one per line, in the format produced by
6+
# the "Annotate commits with author + external flag" workflow step:
7+
#
8+
# <short-sha> <subject> -- author=<github-login> external=<true|false>
9+
#
10+
# Writes TSV to stdout, one line per included commit:
11+
#
12+
# <category>\t<description>\t<pr-ref>\t<login>\t<external>
13+
#
14+
# where <category> is one of: breaking, feature, fix, improvement, enhancement
15+
# and <pr-ref> is either "#NNN" or empty.
16+
#
17+
# Chore-type commits (chore/ci/docs/test/build/style, version-bump and
18+
# release-automation commits) are dropped entirely.
19+
#
20+
# Classification order:
21+
# 1. Conventional-commit prefix (feat:, fix:, perf:, ...). A "!" before the
22+
# colon, or a "BREAKING CHANGE" marker, promotes the commit to breaking.
23+
# 2. Leading imperative verb, for the many historical commits in these repos
24+
# that predate conventional commits.
25+
# 3. Anything left over becomes "enhancement".
26+
27+
set -euo pipefail
28+
29+
# Subjects matching these patterns are release-automation noise, never user-facing.
30+
readonly SKIP_SUBJECT_RE='^(Prepare for |Bump versions|Update changelogs|Applying documentation updates|Merge (branch|pull request|remote-tracking)|Release/|Run rush change|Initial (commit|release)|Resync )'
31+
32+
# Conventional-commit types that never appear in release notes.
33+
readonly SKIP_TYPE_RE='^(chore|ci|docs|test|tests|build|style|revert)$'
34+
35+
# Bare-subject chore detection, for the many commits predating conventional
36+
# commits. Deliberately narrow: it requires a chore *object* (CI, README, API
37+
# docs, the test suite, a linter) so that user-facing changes which merely
38+
# mention a version or a dependency are still included. Verified against the
39+
# existing hand-written CHANGELOGs, which omit exactly these.
40+
readonly SKIP_BARE_RE='(^|[[:space:]])(ci|CI)([[:space:]]|$)|[Ll]inting|[Ll]int issues|(unit|integration|flaky)[[:space:]]+tests?|tests?[[:space:]]+(in|on)[[:space:]]+CI|README|API docs|api docs|[Dd]ocumentation (build|updates|page)|docs\.snowplow\.io|API ref|[Cc]hangelog|GitHub [Aa]ction|publish action|prepare-release|\[skip ci\]|[Cc]laude|CLAUDE\.md|[Aa]gent pipeline|instrumentation|jest tests|[Ss]igning config for demo|[Bb]undlemon|[Cc]overalls|[Dd]ependabot|[Aa]ddress (PR )?review|[Aa]ddress review comments|[Ff]ix(up)? review|[Aa]pply review|[Ss]elf-review|[Rr]ebase|[Mm]erge conflict|[Tt]ypo in (test|CI)'
41+
42+
while IFS= read -r line; do
43+
[[ -z "$line" ]] && continue
44+
45+
# Split the trailing "-- author=... external=..." metadata off the subject.
46+
# The separator is optional: dry runs and manual testing pipe bare
47+
# "<sha> <subject>" lines, and treating those as metadata would leak the
48+
# parsed fields into the description.
49+
if [[ "$line" == *" -- author="* ]]; then
50+
meta="${line##*" -- "}"
51+
head="${line%" -- "*}"
52+
else
53+
meta=""
54+
head="$line"
55+
fi
56+
57+
# The leading short sha is dropped; only the subject drives classification.
58+
subject="${head#* }"
59+
[[ "$subject" == "$head" ]] && subject=""
60+
[[ -z "$subject" ]] && continue
61+
62+
login=""
63+
external="false"
64+
if [[ -n "$meta" ]]; then
65+
if [[ "$meta" =~ author=([^[:space:]]*) ]]; then
66+
login="${BASH_REMATCH[1]}"
67+
fi
68+
if [[ "$meta" =~ external=([^[:space:]]*) ]]; then
69+
external="${BASH_REMATCH[1]}"
70+
fi
71+
fi
72+
73+
# Drop release-automation commits.
74+
if [[ "$subject" =~ $SKIP_SUBJECT_RE ]]; then
75+
continue
76+
fi
77+
78+
breaking="false"
79+
# "BREAKING CHANGE" / "BREAKING-CHANGE" anywhere in the subject is a strong signal.
80+
if [[ "$subject" == *"BREAKING CHANGE"* || "$subject" == *"BREAKING-CHANGE"* ]]; then
81+
breaking="true"
82+
fi
83+
84+
category=""
85+
description="$subject"
86+
87+
# --- Rule 1: conventional-commit prefix ---------------------------------
88+
# Matches "type: ", "type(scope): ", and the breaking "type!: " / "type(scope)!: ".
89+
if [[ "$subject" =~ ^([a-zA-Z]+)(\(([^\)]*)\))?(!)?:[[:space:]]+(.*)$ ]]; then
90+
type="$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')"
91+
bang="${BASH_REMATCH[4]}"
92+
rest="${BASH_REMATCH[5]}"
93+
94+
if [[ "$type" =~ $SKIP_TYPE_RE ]]; then
95+
continue
96+
fi
97+
98+
[[ -n "$bang" ]] && breaking="true"
99+
100+
case "$type" in
101+
feat|feature) category="feature" ;;
102+
fix|bugfix) category="fix" ;;
103+
perf|refactor) category="improvement" ;;
104+
*) category="" ;; # unknown type: fall through to the verb rule
105+
esac
106+
107+
if [[ -n "$category" ]]; then
108+
# Drop the scope. It duplicates information already obvious from the
109+
# description in these repos (e.g. "emitter: wake emitter on signal"),
110+
# and keeping it forces an awkward capitalisation of the scope token.
111+
description="$rest"
112+
fi
113+
fi
114+
115+
# --- Rule 2: leading imperative verb -----------------------------------
116+
# Covers the bare-subject style used throughout these repos' history.
117+
if [[ -z "$category" ]]; then
118+
# Bare chore commits (CI, docs, lint, test-suite upkeep) are not
119+
# user-facing. Only applied here: an explicit "feat:"/"fix:" prefix in
120+
# rule 1 always wins, so a genuine fix mentioning CI is never dropped.
121+
if [[ "$subject" =~ $SKIP_BARE_RE ]]; then
122+
continue
123+
fi
124+
125+
verb="$(printf '%s' "$subject" | awk '{print tolower($1)}')"
126+
case "$verb" in
127+
fix|fixes|fixed|resolve|resolves|correct|corrects|prevent|prevents|address|addresses|avoid|avoids|handle|handles|guard)
128+
category="fix" ;;
129+
add|adds|added|introduce|introduces|support|supports|expose|exposes|implement|implements|allow|allows|enable|enables|create|creates)
130+
category="feature" ;;
131+
improve|improves|update|updates|upgrade|upgrades|refactor|refactors|change|changes|make|makes|migrate|migrates|remove|removes|strip|strips|filter|filters|rename|renames|switch|switches|reduce|reduces|optimise|optimize|simplify|declare|deprecate|deprecates|move|moves|replace|replaces|drop|drops|adjust|adjusts|annotate|clean|unify|tidy|undeprecate|reintroduce)
132+
category="improvement" ;;
133+
*)
134+
category="enhancement" ;;
135+
esac
136+
fi
137+
138+
[[ "$breaking" == "true" ]] && category="breaking"
139+
140+
# Extract a PR/issue reference to preserve at the end of the line.
141+
#
142+
# Subjects may carry two references: a trailing "(#NNN)" squash-merge marker
143+
# added by GitHub, and an inline "(close #NNN)" issue link written by the
144+
# author. Prefer the inline issue reference (it names the user-visible issue)
145+
# and strip both markers so the formatters re-append exactly one.
146+
pr_ref=""
147+
if [[ "$description" =~ \((close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+\#([0-9]+)\) ]]; then
148+
# Preserve the "close" keyword: the existing CHANGELOGs write "(close #720)",
149+
# and it keeps GitHub's issue-closing semantics visible in the notes.
150+
pr_ref="${BASH_REMATCH[1]} #${BASH_REMATCH[3]}"
151+
description="$(printf '%s' "$description" \
152+
| sed -E 's/[[:space:]]*\((close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+#[0-9]+\)//I')"
153+
# Drop a redundant trailing squash marker, e.g. "... (close #720) (#720)".
154+
description="$(printf '%s' "$description" | sed -E 's/[[:space:]]*\(#[0-9]+\)[[:space:]]*$//')"
155+
elif [[ "$description" =~ \(\#([0-9]+)\)[[:space:]]*$ ]]; then
156+
pr_ref="#${BASH_REMATCH[1]}"
157+
description="$(printf '%s' "$description" | sed -E 's/[[:space:]]*\(#[0-9]+\)[[:space:]]*$//')"
158+
elif [[ "$description" =~ \#([0-9]+) ]]; then
159+
pr_ref="#${BASH_REMATCH[1]}"
160+
fi
161+
162+
# Strip an inline BREAKING CHANGE marker; the category already conveys it.
163+
description="$(printf '%s' "$description" \
164+
| sed -E 's/^BREAKING[ -]CHANGE:?[[:space:]]*//; s/[[:space:]]*BREAKING[ -]CHANGE:?[[:space:]]*/ /')"
165+
166+
# Tidy whitespace and drop a trailing period for consistent list formatting.
167+
description="$(printf '%s' "$description" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//; s/\.$//')"
168+
[[ -z "$description" ]] && continue
169+
170+
# Capitalise the first letter so bare conventional-commit bodies read as list items.
171+
first="$(printf '%s' "${description:0:1}" | tr '[:lower:]' '[:upper:]')"
172+
description="${first}${description:1}"
173+
174+
# Empty fields are written as "-": bash's word splitting collapses runs of
175+
# tabs, so a genuinely empty column would shift every later field left.
176+
# The formatters translate "-" back to an empty string.
177+
printf '%s\t%s\t%s\t%s\t%s\n' \
178+
"$category" "$description" "${pr_ref:--}" "${login:--}" "$external"
179+
done

.github/scripts/format-pr-body.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Format classified commits into a release PR body.
4+
#
5+
# Reads the TSV produced by classify-commits.sh on stdin and writes
6+
# GitHub-flavoured markdown to stdout: bullets grouped under bold headers,
7+
# with external contributors credited.
8+
#
9+
# Groups with no entries are omitted. If nothing at all is classifiable the
10+
# script emits a short placeholder rather than an empty body, so the PR is
11+
# never opened with a blank description.
12+
13+
set -euo pipefail
14+
15+
work="$(mktemp -d)"
16+
trap 'rm -rf "$work"' EXIT
17+
18+
# Bucket the incoming rows by category.
19+
while IFS=$'\t' read -r category description pr_ref login external; do
20+
[[ -z "${category:-}" ]] && continue
21+
# classify-commits.sh writes "-" for empty columns; see the note there.
22+
[[ "$pr_ref" == "-" ]] && pr_ref=""
23+
[[ "$login" == "-" ]] && login=""
24+
25+
line="- ${description}"
26+
[[ -n "$pr_ref" ]] && line="${line} (${pr_ref})"
27+
# Credit external contributors only; team members are not called out.
28+
if [[ "$external" == "true" && -n "$login" ]]; then
29+
line="${line} thanks to @${login}"
30+
fi
31+
printf '%s\n' "$line" >> "$work/$category"
32+
done
33+
34+
emit_group() {
35+
local file="$1" header="$2"
36+
[[ -s "$work/$file" ]] || return 0
37+
printf '%s\n' "$header"
38+
cat "$work/$file"
39+
printf '\n'
40+
}
41+
42+
{
43+
emit_group breaking '**Breaking changes:**'
44+
emit_group feature '**New features:**'
45+
emit_group improvement '**Improvements:**'
46+
emit_group fix '**Bug fixes:**'
47+
emit_group enhancement '**Enhancements:**'
48+
} > "$work/body.md"
49+
50+
if [[ -s "$work/body.md" ]]; then
51+
# Trim the trailing blank line left by the last group.
52+
awk 'NF || NR < prev_nonblank' prev_nonblank="$(awk 'NF{n=NR}END{print n}' "$work/body.md")" "$work/body.md"
53+
else
54+
printf '%s\n' 'No user-facing changes in this release.'
55+
fi

.github/scripts/prompts/pr-body.md

Lines changed: 0 additions & 25 deletions
This file was deleted.

0 commit comments

Comments
 (0)