Skip to content
Open
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
77 changes: 77 additions & 0 deletions .github/scripts/release-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Copyright 2026 CloudBlue LLC
# SPDX-License-Identifier: Apache-2.0
#
# Generate PR-level release notes scoped to a single module.
#
# Usage: release-notes.sh <current_tag> <tag_glob> <module_key>
# current_tag e.g. sdk/v0.3.0
# tag_glob e.g. 'sdk/v*' (finds the previous tag of the SAME module)
# module_key one of: core | sdk | contrib | admin
#
# Emits a "## What's Changed" list containing only the PRs whose changed files
# touched the given module's directory, followed by a filtered
# "## New Contributors" section and the module-scoped Full Changelog link.
# Pure path-based filtering: a PR that edits a root-level file (e.g. Makefile)
# counts as a "core" change even if its intent was another module.
#
# Requires: gh (authenticated via GH_TOKEN), git (full history + tags), jq.
set -euo pipefail

cur="$1"; glob="$2"; key="$3"
repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set}"

# Previous tag of the same module (newest tag matching the glob that isn't cur).
prev="$(git tag --list "$glob" --sort=-version:refname | grep -vFx "$cur" | head -n1 || true)"

# Raw GitHub-generated notes for the module's commit range.
if [ -n "$prev" ]; then
raw="$(gh api "repos/$repo/releases/generate-notes" \
-f tag_name="$cur" -f previous_tag_name="$prev" --jq '.body')"
else
raw="$(gh api "repos/$repo/releases/generate-notes" -f tag_name="$cur" --jq '.body')"
fi

# Returns 0 if the file list on stdin contains a file belonging to $key.
module_match() {
case "$key" in
sdk) grep -qE '^sdk/' ;;
admin) grep -qE '^admin/' ;;
contrib) grep -E '^plugins/contrib/' | grep -qvE '^plugins/contrib/microsoft/keyvault/' ;;
# Exclude only the sibling modules (separate go.mod). plugins/reference has
# no go.mod, so it belongs to the root module and must count as core.
core) grep -qvE '^(sdk|plugins/contrib|admin|\.github|docs|\.ai)/' ;;
*) echo "unknown module key: $key" >&2; exit 2 ;;
esac
}

# Decide which PRs to keep (space-separated list of surviving PR numbers).
keepers=""
for pr in $(printf '%s\n' "$raw" | grep -oE 'pull/[0-9]+' | sed 's#pull/##' | sort -un); do
if gh api "repos/$repo/pulls/$pr/files" --paginate --jq '.[].filename' | module_match; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] A transient PRs-API failure silently drops a PR from the notes

In the keepers loop, if gh api ".../pulls/$pr/files" ... | module_match runs under set -o pipefail inside an if. If the API call fails transiently (rate limit, 5xx), the condition evaluates false and the PR is silently excluded from the release notes instead of surfacing an error. Low impact since releases are created as drafts and reviewed by hand, but worth knowing.

Optional hardening: capture the file list into a variable first and fail loudly (or retry) on a non-zero gh api exit, so an API hiccup can't quietly shrink the changelog.

keepers="$keepers $pr"
fi
done

# Re-emit the raw notes, dropping bullet lines whose PR wasn't kept, and
# dropping the "New Contributors" header if nothing survives under it.
printf '%s\n' "$raw" | awk -v keepers="$keepers" '
BEGIN { split(keepers, a, " "); for (i in a) ok[a[i]] = 1 }
# Capture a section header; buffer until we know it has content.
/^## / { flush(); header = $0; have = 0; next }
# A bullet referencing a PR: keep only if that PR survived.
/pull\/[0-9]+/ {
n = $0; sub(/.*pull\//, "", n); sub(/[^0-9].*/, "", n)
if (ok[n]) { buffer = buffer (buffer ? "\n" : "") $0; have = 1 }
next
}
# Full Changelog (and any other prose) — pass through, flushing first.
{ flush(); print }
END { flush() }
function flush() {
if (header != "") {
if (have) { print header; print buffer }
header = ""; buffer = ""; have = 0
} else if (buffer != "") { print buffer; buffer = "" }
}
' | cat -s
92 changes: 65 additions & 27 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ concurrency:

permissions:
contents: write
pull-requests: read # release-notes.sh reads changed files via the PRs API

env:
GOVULNCHECK_VERSION: 'v1.1.4'
Expand Down Expand Up @@ -127,12 +128,18 @@ jobs:
cd dist
sha256sum * > checksums.txt

- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
run: bash .github/scripts/release-notes.sh "${{ github.ref_name }}" 'v*' core > release-notes.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] Tag name is interpolated directly into a run: shell block

These new steps expand ${{ github.ref_name }} (and ${{ steps.version.outputs.*_version }}) inside run: scripts, where the value is substituted before bash parses the line. Git tag names allow shell metacharacters (;, $(), backticks), so a crafted tag could inject commands into the release job. Blast radius is limited to whoever can push tags, and the same pattern already exists in the pre-existing VERSION="${{ github.ref_name }}" build step — so this is defense-in-depth, not a live hole. Same applies to the sdk/contrib/admin "Generate release notes" steps.

Suggested fix: pass the values via env: and reference them quoted, e.g. env: with TAG: ${{ github.ref_name }}, then run: bash .github/scripts/release-notes.sh "$TAG" 'v*' core. Ideally apply the same to the pre-existing VERSION= step for consistency.


- name: Create GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
draft: true
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
generate_release_notes: false
body_path: release-notes.md
files: |
dist/chaperone-*
dist/checksums.txt
Expand All @@ -145,6 +152,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
Expand All @@ -166,21 +175,30 @@ jobs:
id: version
run: echo "sdk_version=${GITHUB_REF_NAME#sdk/}" >> $GITHUB_OUTPUT

- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
run: |
{
echo "## SDK Release ${{ github.ref_name }}"
echo
echo "This is a release of the Chaperone SDK module."
echo
echo "### Installation"
echo '```bash'
echo "go get github.com/cloudblue/chaperone/sdk@${{ steps.version.outputs.sdk_version }}"
echo '```'
echo
bash .github/scripts/release-notes.sh "${{ github.ref_name }}" 'sdk/v*' sdk
} > release-notes.md

- name: Create GitHub Release for SDK
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
draft: true
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
body: |
## SDK Release ${{ github.ref_name }}

This is a release of the Chaperone SDK module.

### Installation
```bash
go get github.com/cloudblue/chaperone/sdk@${{ steps.version.outputs.sdk_version }}
```
generate_release_notes: false
body_path: release-notes.md

# Notify on Contrib releases (no binaries to build)
contrib-release:
Expand All @@ -190,6 +208,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
Expand All @@ -212,21 +232,30 @@ jobs:
id: version
run: echo "contrib_version=${GITHUB_REF_NAME#plugins/contrib/}" >> $GITHUB_OUTPUT

- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
run: |
{
echo "## Contrib Release ${{ github.ref_name }}"
echo
echo "This is a release of the Chaperone Contrib Plugins module."
echo
echo "### Installation"
echo '```bash'
echo "go get github.com/cloudblue/chaperone/plugins/contrib@${{ steps.version.outputs.contrib_version }}"
echo '```'
echo
bash .github/scripts/release-notes.sh "${{ github.ref_name }}" 'plugins/contrib/v*' contrib
} > release-notes.md

- name: Create GitHub Release for Contrib
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
draft: true
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
body: |
## Contrib Release ${{ github.ref_name }}

This is a release of the Chaperone Contrib Plugins module.

### Installation
```bash
go get github.com/cloudblue/chaperone/plugins/contrib@${{ steps.version.outputs.contrib_version }}
```
generate_release_notes: false
body_path: release-notes.md

# Build admin portal binaries (UI embedded into chaperone-admin)
admin-release:
Expand Down Expand Up @@ -317,17 +346,26 @@ jobs:
cd dist
sha256sum * > checksums.txt

- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
run: |
{
echo "## Admin Portal Release ${{ steps.version.outputs.admin_version }}"
echo
echo "Ships the \`chaperone-admin\` binary with the Vue UI embedded."
echo "Use \`chaperone-admin create-user\` to seed the initial admin user."
echo
bash .github/scripts/release-notes.sh "${{ github.ref_name }}" 'admin/v*' admin
} > release-notes.md

- name: Create GitHub Release for Admin
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
draft: true
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
generate_release_notes: false
body_path: release-notes.md
files: |
dist/chaperone-admin-*
dist/checksums.txt
body: |
## Admin Portal Release ${{ steps.version.outputs.admin_version }}

Ships the `chaperone-admin` binary with the Vue UI embedded.
Use `chaperone-admin create-user` to seed the initial admin user.