From cd4f59596a648846686d9b472a71754486ad8251 Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Mon, 31 Aug 2026 13:56:01 +0200 Subject: [PATCH] ci(release): publish changelog entries as release notes The release workflow created GitHub releases with a fixed boilerplate sentence and never read CHANGELOG.md, so entries parked under "## Unreleased" silently missed every release and nothing failed when a tagged version had no changelog entries at all. The changelog is now the single source of the release body. A new scripts/changelog.sh extracts and verifies one section, the metadata job gates the release on the tagged version having entries and on no entries being left unreleased, and the publish job uses that section as the release notes. bump-version.sh promotes the unreleased entries into the dated section it creates, and CI verifies documentation, script syntax, and changelog consistency on every run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 193fe4f0-675a-4655-8754-3b20d216f398 --- .github/workflows/ci.yml | 5 + .github/workflows/release.yml | 44 ++++++++- CHANGELOG.md | 5 + CLAUDE.md | 10 +- README.md | 10 +- scripts/bump-version.sh | 30 +++++- scripts/changelog.sh | 176 +++++++++++++++++++++++++++++++++ scripts/check-documentation.sh | 10 ++ 8 files changed, 285 insertions(+), 5 deletions(-) create mode 100755 scripts/changelog.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index babe527..c5627f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,11 @@ jobs: - name: Prepare results directory run: mkdir -p TestResults + - name: Verify documentation, scripts, and changelog + run: | + bash -n scripts/*.sh + scripts/check-documentation.sh + - name: Run unit tests run: | xcodebuild \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9496b08..9b7964f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,31 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + - name: Checkout release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify changelog and build release notes + shell: bash + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + if ! git rev-parse -q --verify "refs/tags/$RELEASE_TAG" >/dev/null; then + git fetch --no-tags --depth=1 origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + fi + git show "refs/tags/$RELEASE_TAG:CHANGELOG.md" > CHANGELOG.md + scripts/changelog.sh release-notes "$RELEASE_TAG" --output dist/release-notes.md + + - name: Upload release notes + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: PtionsPlus-release-notes + path: dist/release-notes.md + if-no-files-found: error + tests: name: Test release commit needs: metadata @@ -150,6 +175,12 @@ jobs: name: PtionsPlus-release-assets path: release-assets + - name: Download release notes + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: PtionsPlus-release-notes + path: release-notes + - name: Attach assets to GitHub Release shell: bash env: @@ -159,10 +190,19 @@ jobs: RELEASE_VERSION: ${{ needs.metadata.outputs.version }} run: | set -euo pipefail - if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + notes="release-notes/release-notes.md" + if [[ ! -s "$notes" ]]; then + echo "Release notes from the changelog are missing." + exit 1 + fi + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release edit "$RELEASE_TAG" \ + --title "Ptions+ ${RELEASE_VERSION}" \ + --notes-file "$notes" + else gh release create "$RELEASE_TAG" \ --title "Ptions+ ${RELEASE_VERSION}" \ - --notes "Signed, notarized, stapled, and verified macOS release artifacts." + --notes-file "$notes" fi gh release upload "$RELEASE_TAG" \ "release-assets/Ptions+.dmg" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index fd042af..7e4ea42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Added a changelog gate to the release pipeline: a tag whose version has no dated `CHANGELOG.md` section, or that leaves entries under `## Unreleased`, now fails before anything is signed. +- Added `scripts/changelog.sh` and made the GitHub release body the changelog section of the released version instead of fixed boilerplate text. +- Added changelog promotion to `scripts/bump-version.sh`, which moves the unreleased entries into a dated section for the new version. +- Added documentation and changelog verification to CI so version, website, and changelog metadata cannot drift apart. + ## 1.2.0 - 2026-08-31 - Added Next Space and Previous Space preset actions so spaces can be switched from a mouse button (#28). diff --git a/CLAUDE.md b/CLAUDE.md index b8c2184..90f9a93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,9 +23,12 @@ xcodebuild -project PtionsPlus.xcodeproj -scheme "Ptions+" \ xcodebuild -project PtionsPlus.xcodeproj -scheme "Ptions+" \ -configuration Debug CODE_SIGNING_ALLOWED=NO analyze -# Bump version and website metadata +# Bump version, website metadata, and promote the changelog ./scripts/bump-version.sh patch # or: minor, major +# Preview the release notes a tag would publish +scripts/changelog.sh release-notes v1.2.0 + # Build signed archive, notarize/staple, and create verified ZIP/DMG scripts/setup-notarization.sh --gui bash scripts/sign-release.sh @@ -38,6 +41,11 @@ bash -n scripts/*.sh Local release overrides live in `.release.env` and are ignored by git. +Release notes are not written by hand. Every user-facing change goes under `## Unreleased` in `CHANGELOG.md`, +`bump-version.sh` promotes those entries into a dated `## X.Y.Z` section, and the release workflow publishes that +section as the GitHub release body. A tag whose version has no changelog entries, or that leaves entries under +`## Unreleased`, fails before anything is signed. + ## Architecture `AppDelegate` owns the shared services and starts `ActiveAppMonitor`, lifetime Accessibility monitoring, and `RuntimeServiceCoordinator`. diff --git a/README.md b/README.md index 5261f2d..446261b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ open /Applications/Ptions+.app ### Signed Release Build +Every release describes itself through `CHANGELOG.md`. Add each user-facing change under `## Unreleased` while you work; `bump-version.sh` then promotes those entries into a dated section for the new version, and the release workflow publishes exactly that section as the GitHub release notes. + The release flow is three commands: bump version, sign, notarize. First create your local release config: @@ -99,6 +101,12 @@ xcrun notarytool store-credentials "PtionsPlus" \ bash scripts/notarize.sh ``` +Preview the notes a tag would publish: + +```bash +scripts/changelog.sh release-notes v1.2.0 +``` + If you already have a working `notarytool` keychain profile from another project, set it in `.release.env` or inline: ```bash @@ -120,7 +128,7 @@ The final ZIP and DMG are rebuilt from the stapled app and verified before uploa shasum -a 256 -c Ptions+.dmg.sha256 ``` -The GitHub release workflow builds signed, notarized artifacts on `v*` tags. Configure these repository secrets first: +The GitHub release workflow builds signed, notarized artifacts on `v*` tags. It refuses to release when the changelog has no entries for the tagged version or when entries are still parked under `## Unreleased`. Configure these repository secrets first: `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PWD`, `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_PASSWORD`. ### Grant Accessibility Access diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 9fae8da..8056b97 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -5,6 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" PROJECT_FILE="$PROJECT_DIR/PtionsPlus.xcodeproj/project.pbxproj" WEBSITE_FILE="$PROJECT_DIR/docs/index.html" +CHANGELOG_FILE="$PROJECT_DIR/CHANGELOG.md" if [ $# -ne 1 ]; then echo "Usage: $0 patch|minor|major" @@ -39,8 +40,35 @@ esac next_version="$major.$minor.$patch" next_build=$((current_build + 1)) +release_date="$(date +%F)" + +unreleased_entries="$("$SCRIPT_DIR/changelog.sh" section unreleased)" +if [ -z "$unreleased_entries" ]; then + echo "Error: CHANGELOG.md has no entries under '## Unreleased'." + echo "Describe the changes of $next_version there before bumping the version." + exit 1 +fi + +if [ -n "$("$SCRIPT_DIR/changelog.sh" section "$next_version")" ]; then + echo "Error: CHANGELOG.md already has a section for $next_version." + exit 1 +fi perl -0pi -e "s/MARKETING_VERSION = \Q$current_version\E;/MARKETING_VERSION = $next_version;/g; s/CURRENT_PROJECT_VERSION = \Q$current_build\E;/CURRENT_PROJECT_VERSION = $next_build;/g" "$PROJECT_FILE" perl -0pi -e "s/\"softwareVersion\": \"[^\"]+\"/\"softwareVersion\": \"$next_version\"/" "$WEBSITE_FILE" -echo "Updated version: $current_version ($current_build) -> $next_version ($next_build)" \ No newline at end of file +changelog_tmp="$(mktemp)" +awk -v heading="## $next_version - $release_date" ' + { print } + !inserted && $0 == "## Unreleased" { + print "" + print heading + inserted = 1 + } + END { if (!inserted) exit 1 } +' "$CHANGELOG_FILE" > "$changelog_tmp" +cat "$changelog_tmp" > "$CHANGELOG_FILE" +rm -f "$changelog_tmp" + +echo "Updated version: $current_version ($current_build) -> $next_version ($next_build)" +echo "Promoted the unreleased changelog entries into '## $next_version - $release_date'." diff --git a/scripts/changelog.sh b/scripts/changelog.sh new file mode 100755 index 0000000..21c1368 --- /dev/null +++ b/scripts/changelog.sh @@ -0,0 +1,176 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +CHANGELOG_FILE="$PROJECT_DIR/CHANGELOG.md" +UNRELEASED_HEADING="## Unreleased" + +usage() { + cat <<'USAGE' +Usage: + scripts/changelog.sh section + scripts/changelog.sh release-notes [--output FILE] + +section Print the entries of one changelog section without its heading. +release-notes Verify the changelog is release ready for one version and print + its entries, optionally writing them to FILE. + +The release-notes check fails when the version has no dated section, when that +section has no entries, or when entries are still parked under "## Unreleased" +and would therefore never reach any release notes. A tag that predates the +"## Unreleased" convention has no parked entries and passes. +USAGE +} + +normalize_version() { + local value="${1#v}" + if [[ ! "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: invalid version '$1'; expected X.Y.Z or vX.Y.Z." >&2 + exit 1 + fi + printf '%s\n' "$value" +} + +heading_line() { + awk -v prefix="$1" 'index($0, prefix) == 1 { print; exit }' "$CHANGELOG_FILE" +} + +section_body() { + awk -v prefix="$1" ' + started && index($0, "## ") == 1 { exit } + started { print } + !started && index($0, prefix) == 1 { started = 1 } + ' "$CHANGELOG_FILE" | awk ' + NF { if (!first) first = NR; last = NR } + { lines[NR] = $0 } + END { + if (!first) exit + for (i = first; i <= last; i++) print lines[i] + } + ' +} + +require_changelog() { + if [ ! -f "$CHANGELOG_FILE" ]; then + echo "Error: $CHANGELOG_FILE not found." >&2 + exit 1 + fi +} + +require_unreleased_heading() { + if [ -z "$(heading_line "$UNRELEASED_HEADING")" ]; then + echo "Error: CHANGELOG.md must keep an '$UNRELEASED_HEADING' section." >&2 + exit 1 + fi +} + +cmd_section() { + local target="${1:-}" + if [ -z "$target" ]; then + usage >&2 + exit 1 + fi + + case "$target" in + unreleased | Unreleased) + require_unreleased_heading + section_body "$UNRELEASED_HEADING" + ;; + *) + local version + version="$(normalize_version "$target")" + section_body "## $version - " + ;; + esac +} + +cmd_release_notes() { + local target="" output="" + + while [ $# -gt 0 ]; do + case "$1" in + --output) + output="${2:-}" + shift 2 + ;; + -*) + usage >&2 + exit 1 + ;; + *) + if [ -n "$target" ]; then + usage >&2 + exit 1 + fi + target="$1" + shift + ;; + esac + done + + if [ -z "$target" ]; then + usage >&2 + exit 1 + fi + + local version + version="$(normalize_version "$target")" + + local unreleased + unreleased="$(section_body "$UNRELEASED_HEADING")" + if [ -n "$unreleased" ]; then + echo "Error: unreleased entries would bypass release $version:" >&2 + printf '%s\n' "$unreleased" >&2 + echo "Promote them into a released section with scripts/bump-version.sh." >&2 + exit 1 + fi + + local heading + heading="$(heading_line "## $version - ")" + if [ -z "$heading" ]; then + echo "Error: CHANGELOG.md has no '## $version - YYYY-MM-DD' section." >&2 + exit 1 + fi + + if [[ ! "$heading" =~ ^##\ "$version"\ -\ [0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "Error: changelog heading '$heading' must read '## $version - YYYY-MM-DD'." >&2 + exit 1 + fi + + local notes + notes="$(section_body "## $version - ")" + if [ -z "$notes" ]; then + echo "Error: changelog section for $version has no entries." >&2 + exit 1 + fi + + if [ -n "$output" ]; then + mkdir -p "$(dirname "$output")" + printf '%s\n' "$notes" > "$output" + echo "Release notes for $version written to $output" + else + printf '%s\n' "$notes" + fi +} + +require_changelog + +COMMAND="${1:-}" +[ $# -gt 0 ] && shift + +case "$COMMAND" in + section) + cmd_section "$@" + ;; + release-notes) + cmd_release_notes "$@" + ;; + -h | --help | help) + usage + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/scripts/check-documentation.sh b/scripts/check-documentation.sh index d902949..7c25fe2 100755 --- a/scripts/check-documentation.sh +++ b/scripts/check-documentation.sh @@ -43,4 +43,14 @@ if ! grep -q 'MX Master 4' "$PROJECT_DIR/docs/index.html"; then exit 1 fi +if ! grep -q '^## Unreleased$' "$PROJECT_DIR/CHANGELOG.md"; then + echo "Error: CHANGELOG.md must keep an '## Unreleased' section." + exit 1 +fi + +if [ -z "$("$SCRIPT_DIR/changelog.sh" section "$MARKETING_VERSION")" ]; then + echo "Error: CHANGELOG.md has no entries for version $MARKETING_VERSION." + exit 1 +fi + echo "Documentation references verified."