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
10 changes: 7 additions & 3 deletions .github/workflows/propagate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ name: Propagate Release
# Pushes a published release out to the repos that reference it:
# - gitlab.com/getplumber/plumber (CI/CD Catalog component): sync
# templates/plumber.yml, rewrite "pinned plumber version" marked README
# lines, write RELEASE_NOTES.md, commit to main and tag v${VERSION}. The
# lines, write RELEASE_NOTES.md (the component's own changes, built by
# scripts/release-component-notes.sh; the GitHub release keeps the full
# CLI changelog), commit to main and tag v${VERSION}. The
# GitLab tag pipeline then creates the release (Catalog publish requires
# a CI-created release, so the tag pipeline is the only valid path).
# Two commits land on main: the content sync (untagged), then a
Expand Down Expand Up @@ -77,7 +79,7 @@ jobs:
env:
VERSION: ${{ inputs.version }}
DIGEST_INPUT: ${{ inputs.digest }}
GH_TOKEN: ${{ github.token }}
CLI_URL: ${{ github.server_url }}/${{ github.repository }}
run: |
set -euo pipefail
SHA="$(git rev-parse "v${VERSION}^{commit}")"
Expand All @@ -86,7 +88,9 @@ jobs:
echo "digest mismatch: docker job says ${DIGEST_INPUT}, template has ${DIGEST}" >&2
exit 1
fi
gh release view "v${VERSION}" --json body --jq .body > /tmp/release-notes.md
# Not the GitHub release body: component users only need what
# changed in templates/plumber.yml, plus a link to the CLI release.
bash scripts/release-component-notes.sh "$VERSION" "$DIGEST" > /tmp/release-notes.md
test -s /tmp/release-notes.md
{ echo "sha=${SHA}"; echo "digest=${DIGEST}"; } >> "$GITHUB_OUTPUT"

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/scripts-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ jobs:
bash scripts/release-template-digest.test.sh
bash scripts/release-bump-version.test.sh
bash scripts/release-plan.test.sh
bash scripts/release-component-notes.test.sh
107 changes: 107 additions & 0 deletions scripts/release-component-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Print the GitLab component release notes for one release, in the Keep a
# Changelog 1.1.0 format (https://keepachangelog.com/en/1.1.0/).
#
# The component is templates/plumber.yml, so the notes only describe what
# changed in it: the image the release pins, plus one entry per commit
# touching templates/ since the previous release tag. The CLI changelog
# (GitHub-only controls, CI bumps, tests...) is left to the GitHub release,
# which the notes link to.
#
# Commits are read from <repo-dir> (the CLI repository, where templates/
# lives), between the highest vX.Y.Z tag below v<version> and v<version>.
# Release commits (any "(release)" scope: chore(release) today,
# ci(release) in older releases) are skipped: their only template change is the
# digest bump, already covered by the image entry. Conventional commit
# subjects map to sections:
# security type or scope -> Security
# subject saying deprecate -> Deprecated
# first word remove/drop -> Removed
# feat -> Added
# fix -> Fixed
# anything else -> Changed
# A breaking change ("!" or a BREAKING CHANGE footer) gets a "Breaking:"
# prefix, wherever it lands. Empty sections are left out.
#
# Usage: release-component-notes.sh <version> <digest> [repo-dir]
# Env: COMPONENT_URL GitLab component project (default gitlab.com/getplumber/plumber)
# CLI_URL GitHub CLI repository (default github.com/getplumber/plumber)
set -euo pipefail
ver="${1:?version required}"
digest="${2:?digest required}"
dir="${3:-.}"
component_url="${COMPONENT_URL:-https://gitlab.com/getplumber/plumber}"
cli_url="${CLI_URL:-https://github.com/getplumber/plumber}"

[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "invalid digest: '${digest}'" >&2; exit 1; }
tag="v${ver}"
git -C "$dir" rev-parse -q --verify "refs/tags/${tag}" >/dev/null || { echo "tag ${tag} not found in ${dir}" >&2; exit 1; }

# The tag right before ours in version order. Empty for the first release.
prev="$(git -C "$dir" tag --list 'v*' --sort=v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -B1 -x "$tag" | grep -vx "$tag" || true)"
range="$tag"
if [ -n "$prev" ]; then range="${prev}..${tag}"; fi

added=() changed=() deprecated=() removed=() fixed=() security=()
changed+=("Image bumped to \`getplumber/plumber:${ver}\` (\`${digest}\`).")

re='^([a-zA-Z]+)(\(([^)]*)\))?(!)?: (.+)$'
while IFS= read -r sha; do
[ -n "$sha" ] || continue
subject="$(git -C "$dir" log -1 --format=%s "$sha")"
body="$(git -C "$dir" log -1 --format=%b "$sha")"
type="" scope="" bang="" desc="$subject"
if [[ "$subject" =~ $re ]]; then
type="$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')"
scope="${BASH_REMATCH[3]}"
bang="${BASH_REMATCH[4]}"
desc="${BASH_REMATCH[5]}"
fi
if [ "$scope" = "release" ]; then continue; fi

entry="$(printf '%s' "${desc:0:1}" | tr '[:lower:]' '[:upper:]')${desc:1}"
if ! [[ "$entry" =~ [.!?]$ ]]; then entry="${entry}."; fi
if [ -n "$bang" ] || printf '%s\n' "$body" | grep -qE '^BREAKING[ -]CHANGE:'; then
entry="**Breaking:** ${entry}"
fi

if [ "$type" = "security" ] || [[ ",${scope}," =~ ,[[:space:]]*security[[:space:]]*, ]]; then
security+=("$entry")
elif printf '%s' "$desc" | grep -qi 'deprecat'; then
deprecated+=("$entry")
elif printf '%s' "$desc" | grep -qiE '^(remov(e|es|ed|ing)|drop(s|ped|ping)?)([^[:alnum:]_-]|$)'; then
removed+=("$entry")
elif [ "$type" = "feat" ]; then
added+=("$entry")
elif [ "$type" = "fix" ]; then
fixed+=("$entry")
else
changed+=("$entry")
fi
done < <(git -C "$dir" log --no-merges --reverse --format=%H "$range" -- templates/)

section() {
local title="$1"; shift
[ "$#" -gt 0 ] || return 0
printf '\n### %s\n\n' "$title"
printf -- '- %s\n' "$@"
}

date="$(git -C "$dir" log -1 --format=%cs "${tag}^{commit}")"
if [ -n "$prev" ]; then
link="${component_url}/-/compare/${prev}...${tag}"
else
link="${component_url}/-/tags/${tag}"
fi

printf '## [%s] - %s\n' "$ver" "$date"
section Added ${added[@]+"${added[@]}"}
section Changed "${changed[@]}"
section Deprecated ${deprecated[@]+"${deprecated[@]}"}
section Removed ${removed[@]+"${removed[@]}"}
section Fixed ${fixed[@]+"${fixed[@]}"}
section Security ${security[@]+"${security[@]}"}
printf '\nThe full CLI changelog is in the [%s GitHub release](%s/releases/tag/%s).\n' "$tag" "$cli_url" "$tag"
printf '\n[%s]: %s\n' "$ver" "$link"
124 changes: 124 additions & 0 deletions scripts/release-component-notes.test.sh

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Test coverage

Low: the security-by-type classification branch is never exercised by a test — only the security-by-scope branch is. (Low)

The section classifier in scripts/release-component-notes.sh:70 routes a commit to Security when either its conventional-commit type is security or its scope contains security:

if [ "$type" = "security" ] || [[ ",${scope}," =~ ,[[:space:]]*security[[:space:]]*, ]]; then

The test suite only covers the scope branch — scripts/release-component-notes.test.sh:230 commits fix(component,security): do not echo the token and asserts it lands under ### Security. There is no test for a security-typed commit (e.g. security: patch the token leak or security(component): rotate keys), even though the script's own header comment documents this mapping ("security type or scope -> Security", scripts/release-component-notes.sh:17).

Because the [ "$type" = "security" ] disjunct is never executed, a regression affecting only that branch — a typo, or a refactor that collapses the condition to scope-only — would silently misclassify security-typed commits into the Changed section, producing incorrect GitLab component release notes, and the full test run would still report all tests passed.

Suggested test: add a commit commit templates/plumber.yml "security: patch the token leak" (plain security type, no scope) before the v0.8.0 release in the section-coverage block, and assert its description appears under ### Security (and does not appear under ### Changed). This closes the second, currently-unverified half of the Security routing logic.

Control: .github/claude-prompts/test-coverage.md · resolve this conversation once addressed.

Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Unit tests for release-component-notes.sh.
# Run: bash scripts/release-component-notes.test.sh
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
script="$here/release-component-notes.sh"
fails=0
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

DIGEST="sha256:$(printf 'c%.0s' {1..64})"
repo="$tmp/repo"
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
git init -q "$repo"
mkdir -p "$repo/templates" "$repo/control"

# commit <file> <subject> [body]: append a line to <file> and commit it.
commit() {
echo "$2" >> "$repo/$1"
git -C "$repo" add -A
if [ -n "${3:-}" ]; then
git -C "$repo" commit -q -m "$2" -m "$3"
else
git -C "$repo" commit -q -m "$2"
fi
}
release() {
commit templates/plumber.yml "chore(release): $1 [skip ci]"
git -C "$repo" tag "v$1"
}

check() { # check <name> <output> <grep -F needle>
if printf '%s\n' "$2" | grep -qF -- "$3"; then echo "ok: $1"; else echo "FAIL: $1 (missing: $3)"; fails=$((fails+1)); fi
}
check_not() {
if printf '%s\n' "$2" | grep -qF -- "$3"; then echo "FAIL: $1 (unexpected: $3)"; fails=$((fails+1)); else echo "ok: $1"; fi
}
# section <output> <title>: the entries of one "### <title>" section.
section() { printf '%s\n' "$1" | awk -v t="### $2" '$0 == t {on=1; next} /^### / {on=0} on && /^- /'; }

# v0.7.9: first release, no previous tag.
commit templates/plumber.yml "feat(component): initial template"
release 0.7.9
out="$(bash "$script" 0.7.9 "$DIGEST" "$repo")"
check "heading with ISO date" "$out" "## [0.7.9] - $(git -C "$repo" log -1 --format=%cs v0.7.9)"
check "first release links the tag page" "$out" "[0.7.9]: https://gitlab.com/getplumber/plumber/-/tags/v0.7.9"
check "first release lists every template commit" "$out" "- Initial template."

# v0.7.10: digest-only release, with a CLI-only change in between.
commit control/x.go "feat(github): a GitHub-only control"
release 0.7.10
out="$(bash "$script" 0.7.10 "$DIGEST" "$repo")"
check "previous tag sorts by version (0.7.9 < 0.7.10)" "$out" "[0.7.10]: https://gitlab.com/getplumber/plumber/-/compare/v0.7.9...v0.7.10"
check "image entry under Changed" "$out" "- Image bumped to \`getplumber/plumber:0.7.10\` (\`${DIGEST}\`)."
check "full CLI changelog link" "$out" "The full CLI changelog is in the [v0.7.10 GitHub release](https://github.com/getplumber/plumber/releases/tag/v0.7.10)."
check_not "CLI-only commit left out" "$out" "GitHub-only control"
check_not "release commit left out" "$out" "skip ci"
check_not "no empty Added section" "$out" "### Added"
if [ "$(printf '%s\n' "$out" | grep -c '^### ')" = 1 ]; then echo "ok: digest-only release has only Changed"; else echo "FAIL: digest-only release has only Changed"; fails=$((fails+1)); fi

# v0.8.0: every section, in order.
commit templates/plumber.yml "feat(component): add a report_format input"
commit templates/plumber.yml "fix(component): quote the threshold"
commit templates/plumber.yml "feat(component)!: rename the config input"
commit templates/plumber.yml "refactor: split the script" "BREAKING CHANGE: the job name changed"
commit templates/plumber.yml "feat(component): deprecate the legacy input"
commit templates/plumber.yml "feat(component): dropdown selector for the report"
commit templates/plumber.yml "feat(component): drop-in replacement for the job"
commit templates/plumber.yml "feat(component): remove the verbose input"
commit templates/plumber.yml "fix(component,security): do not echo the token"
commit templates/plumber.yml "security: patch the token leak"
commit templates/plumber.yml "docs(component): explain the inputs"
commit templates/plumber.yml "Tweak the rules ordering"
commit templates/plumber.yml "ci(release): pin v0.7.11 refs [skip ci]"
commit control/x.go "fix(github): a GitHub-only fix"
release 0.8.0
out="$(bash "$script" 0.8.0 "$DIGEST" "$repo")"
check "feat -> Added" "$(section "$out" Added)" "- Add a report_format input."
check "fix -> Fixed" "$(section "$out" Fixed)" "- Quote the threshold."
check "bang marks breaking" "$out" "- **Breaking:** Rename the config input."
check "footer marks breaking" "$out" "- **Breaking:** Split the script."
check "deprecate -> Deprecated" "$(section "$out" Deprecated)" "- Deprecate the legacy input."
check "remove -> Removed" "$(section "$out" Removed)" "- Remove the verbose input."
check "dropdown -> Added" "$(section "$out" Added)" "- Dropdown selector for the report."
check "drop-in -> Added" "$(section "$out" Added)" "- Drop-in replacement for the job."
check_not "dropdown is not a removal" "$(section "$out" Removed)" "Dropdown"
check_not "drop-in is not a removal" "$(section "$out" Removed)" "Drop-in"
check "security scope -> Security" "$(section "$out" Security)" "- Do not echo the token."
check "security type -> Security" "$(section "$out" Security)" "- Patch the token leak."
check "other types -> Changed" "$out" "- Explain the inputs."
check "non-conventional subject -> Changed, capitalized" "$out" "- Tweak the rules ordering."
check_not "ci(release) commit left out" "$out" "Pin v0.7.11"
check_not "CLI-only fix left out" "$out" "GitHub-only fix"
order="$(printf '%s\n' "$out" | grep '^### ' | tr '\n' ' ')"
if [ "$order" = "### Added ### Changed ### Deprecated ### Removed ### Fixed ### Security " ]; then
echo "ok: Keep a Changelog section order"
else
echo "FAIL: Keep a Changelog section order (got: $order)"; fails=$((fails+1))
fi
if printf '%s\n' "$out" | grep -qE '^- (Image|Explain|Tweak|\*\*Breaking:\*\* Split)' \
&& [ "$(printf '%s\n' "$out" | sed -n '/^### Changed/,/^### /p' | grep -c '^- ')" = 4 ]; then
echo "ok: Changed holds the image and the three other changes"
else
echo "FAIL: Changed holds the image and the three other changes"; fails=$((fails+1))
fi

# URL overrides (the e2e lab points at its own projects).
out="$(COMPONENT_URL=https://gitlab.example.com/lab CLI_URL=https://github.com/example/lab bash "$script" 0.8.0 "$DIGEST" "$repo")"
check "COMPONENT_URL override" "$out" "[0.8.0]: https://gitlab.example.com/lab/-/compare/v0.7.10...v0.8.0"
check "CLI_URL override" "$out" "(https://github.com/example/lab/releases/tag/v0.8.0)"

# Bad input: fail with a diagnostic.
set +e
out="$(bash "$script" 0.8.0 "sha256:nothex" "$repo" 2>&1)"; rc=$?
set -e
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'invalid digest'; then echo "ok: bad digest rejected"; else echo "FAIL: bad digest rejected (rc=$rc out=$out)"; fails=$((fails+1)); fi
set +e
out="$(bash "$script" 9.9.9 "$DIGEST" "$repo" 2>&1)"; rc=$?
set -e
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'tag v9.9.9 not found'; then echo "ok: missing tag rejected"; else echo "FAIL: missing tag rejected (rc=$rc out=$out)"; fails=$((fails+1)); fi

if [ "$fails" -ne 0 ]; then echo "$fails test(s) failed"; exit 1; fi
echo "all tests passed"
Loading