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
44 changes: 42 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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" \
Expand Down
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ bash scripts/sign-release.sh
bash scripts/notarize.sh
```

Release notes are never written by hand. Every user-facing change is described
under `## Unreleased` in `CHANGELOG.md`, `bump-version.sh` promotes those
entries into the dated `## X.Y.Z` section it creates, and the release workflow
publishes exactly 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 built or signed.

```bash
# Preview the notes a tag would publish
scripts/changelog.sh release-notes v1.2.0
```

## High-risk operations

Never perform any of the following without an explicit, specific instruction
Expand Down Expand Up @@ -141,6 +153,7 @@ Generated or tool-owned. Do not hand-edit.
| `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `PtionsPlus.xcodeproj/project.pbxproj` | `scripts/bump-version.sh` |
| `PtionsPlus/Utilities/ProductIdentity.swift` version constants | Derived from the bundle at runtime; values come from the build |
| `"softwareVersion"` and the version badge in `docs/index.html` | `scripts/bump-version.sh` |
| The dated `## X.Y.Z` headings in `CHANGELOG.md` | `scripts/bump-version.sh` promotes the entries you wrote under `## Unreleased` |
| `docs/assets/core.tokens.css`, `docs/assets/instrument-workshop.css`, `docs/assets/instrument-workshop-fonts.css`, `docs/assets/fonts/**` | Vendored from `trsdn/design-system`. Re-vendor from a tag; never patch in place. |
| `.github/badges/*.svg` | Generated from `.github/conformance.yml` and the stats workflow |
| `build/`, `dist/`, `TestResults/` | Build output, gitignored |
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

- Added an About window reachable from the menu bar that shows the app version and links to the repository, issue tracker, and licence (#32).
- Reworked the published website onto vendored design-system styling with self-hosted fonts, removing the third-party font requests that previously observed every visitor (#32).
- Added `AGENTS.md` as the single home for build, test, and release instructions, plus `scripts/validate.sh` as one validation command (#32).
- 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 built or 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 changelog verification to the documentation checks, so the version being shipped is always described.

## 1.2.0 - 2026-08-31

- Added Next Space and Previous Space preset actions so spaces can be switched from a mouse button (#28).
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ Build, test, lint, and release commands are documented in

### 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:
Expand All @@ -138,6 +140,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
Expand All @@ -155,7 +163,7 @@ The release scripts produce four outputs:

The final ZIP and DMG are rebuilt from the stapled app and verified before upload.

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`.

## Versioning and compatibility
Expand Down
2 changes: 1 addition & 1 deletion docs/self-assessment.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Not applicable, with rationale:
| R03 | pass | A `v*` tag runs [`release.yml`](../.github/workflows/release.yml), which builds, signs, notarises, staples, and uploads a ZIP, a DMG, and a SHA-256 file. |
| R04 | pass | `scripts/verify-version.sh --tag` rejects a tag that does not match `MARKETING_VERSION`. |
| R05 | pass | `scripts/verify-release-artifacts.sh` rebuilds the ZIP and DMG from the stapled app and verifies them before upload; the release workflow reruns the full CI suite against the tagged commit first. |
| R06 | pass | `CHANGELOG.md` and GitHub release notes. |
| R06 | pass | The release workflow gates on `scripts/changelog.sh release-notes` and publishes the `CHANGELOG.md` section of the tagged version as the release body, so a release cannot ship without described changes. |

## Product Identity

Expand Down
35 changes: 32 additions & 3 deletions scripts/bump-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -39,17 +40,45 @@ esac

next_version="$major.$minor.$patch"
next_build=$((current_build + 1))
release_date=$(date -u +%Y-%m-%d)

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"

# The site is machine-owned for these three facts: the structured-data version,
# every visible version marker, and the review date the page publishes.
review_date_iso=$(date -u +%Y-%m-%d)
review_date_human=$(date -u "+%-d %B %Y")

perl -0pi -e "s/\"softwareVersion\": \"[^\"]+\"/\"softwareVersion\": \"$next_version\"/" "$WEBSITE_FILE"
perl -0pi -e "s/(<span data-app-version>)[^<]*(<\/span>)/\${1}$next_version\${2}/g" "$WEBSITE_FILE"
perl -0pi -e "s/<time datetime=\"[^\"]*\" data-reviewed>[^<]*<\/time>/<time datetime=\"$review_date_iso\" data-reviewed>$review_date_human<\/time>/" "$WEBSITE_FILE"
perl -0pi -e "s/<time datetime=\"[^\"]*\" data-reviewed>[^<]*<\/time>/<time datetime=\"$release_date\" data-reviewed>$review_date_human<\/time>/" "$WEBSITE_FILE"

# The changelog is the source of the release notes, so the entries collected
# under "## Unreleased" become the section of the version being cut.
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 "Site version markers and review date ($review_date_iso) updated."
echo "Site version markers and review date ($release_date) updated."
echo "Promoted the unreleased changelog entries into '## $next_version - $release_date'."
176 changes: 176 additions & 0 deletions scripts/changelog.sh
Original file line number Diff line number Diff line change
@@ -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 <version|unreleased>
scripts/changelog.sh release-notes <version|vX.Y.Z> [--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
Loading