diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4337499 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: release + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag to (re)release (e.g. v0.1.0)" + required: true + type: string + +permissions: + contents: write + +jobs: + release: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine tag + id: tag + shell: bash + run: | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout release tag + if: ${{ github.event_name == 'workflow_dispatch' }} + run: git checkout ${{ inputs.tag }} + + - name: Resolve packages + run: swift package resolve + + - name: Sync version + run: scripts/generate-version.sh + + - name: Build + run: swift build -c release --product remindctl + + - name: Codesign + run: codesign --force --sign - --identifier com.steipete.remindctl .build/release/remindctl + + - name: Package artifact + run: | + mkdir -p dist + cp .build/release/remindctl dist/remindctl + ( + cd dist + zip -r remindctl-macos.zip remindctl + ) + + - name: Publish release assets + uses: softprops/action-gh-release@v2 + with: + files: dist/remindctl-macos.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update GitHub release notes from CHANGELOG + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + version="${TAG#v}" + notes_file="/tmp/release-notes.md" + + awk -v v="$version" ' + $0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next } + in_section && $0 ~ "^## " { exit } + in_section { print } + ' CHANGELOG.md > "$notes_file" + + if ! grep -q '[^[:space:]]' "$notes_file"; then + echo "No CHANGELOG.md section found for version $version" >&2 + exit 1 + fi + + gh release edit "$TAG" --notes-file "$notes_file" diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..29dd617 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,38 @@ +# Releasing + +## Release notes source +- GitHub Release notes come from `CHANGELOG.md` for the matching version section (`## X.Y.Z - YYYY-MM-DD`). + +## Steps +1. Update changelog and version + - Ensure `CHANGELOG.md` has `## 0.1.0 - YYYY-MM-DD` with final notes. + - Update `version.env` to `0.1.0` (already set for the first release). + - Run `scripts/generate-version.sh` (refreshes `Sources/remindctl/Version.swift` + embedded Info.plist). +2. Ensure checks are green + - `make check` +3. Build, sign, and notarize (local) + - Requires `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`. + - `scripts/sign-and-notarize.sh` (outputs `/tmp/remindctl-macos.zip` by default). +4. Tag, push, and publish + - `git tag -a v0.1.0 -m "v0.1.0"` + - `git push origin v0.1.0` + - Extract release notes: + ```sh + version=0.1.0 + notes_file=/tmp/release-notes.txt + awk -v v="$version" ' + $0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next } + in_section && $0 ~ "^## " { exit } + in_section { print } + ' CHANGELOG.md > "$notes_file" + ``` + - Create GitHub release: + ```sh + gh release create v0.1.0 /tmp/remindctl-macos.zip -t "v0.1.0" -F /tmp/release-notes.txt + ``` +5. Homebrew tap + - Update `../homebrew-tap/Formula/remindctl.rb` to point at the GitHub release asset. + +## What happens in CI +- Release signing + notarization are done locally via `scripts/sign-and-notarize.sh`. +- `.github/workflows/release.yml` is only for manual rebuilds, not the primary release path. diff --git a/scripts/sign-and-notarize.sh b/scripts/sign-and-notarize.sh new file mode 100755 index 0000000..f1b3845 --- /dev/null +++ b/scripts/sign-and-notarize.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +source "$ROOT/version.env" + +APP_NAME="remindctl" +CODESIGN_IDENTITY=${CODESIGN_IDENTITY:-"Developer ID Application: Peter Steinberger (Y5PE65HELJ)"} +ENTITLEMENTS="${ROOT}/Resources/remindctl.entitlements" +OUTPUT_DIR=${OUTPUT_DIR:-/tmp} +ZIP_PATH="${OUTPUT_DIR}/remindctl-macos.zip" +ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} +ARCH_LIST=( ${ARCHES_VALUE} ) +DIST_DIR="$(mktemp -d "/tmp/${APP_NAME}-dist.XXXXXX")" +API_KEY_FILE="$(mktemp "/tmp/${APP_NAME}-notary.XXXXXX.p8")" + +cleanup() { + rm -f "$API_KEY_FILE" + rm -rf "$DIST_DIR" +} +trap cleanup EXIT + +if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then + echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2 + exit 1 +fi + +echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_FILE" + +"$ROOT/scripts/generate-version.sh" + +for ARCH in "${ARCH_LIST[@]}"; do + swift build -c release --product remindctl --arch "$ARCH" +done + +BINARIES=() +for ARCH in "${ARCH_LIST[@]}"; do + BINARIES+=("$ROOT/.build/${ARCH}-apple-macosx/release/remindctl") +done + +lipo -create "${BINARIES[@]}" -output "$DIST_DIR/remindctl" + +if [[ -f "$ENTITLEMENTS" ]]; then + codesign --force --timestamp --options runtime --sign "$CODESIGN_IDENTITY" \ + --entitlements "$ENTITLEMENTS" \ + "$DIST_DIR/remindctl" +else + codesign --force --timestamp --options runtime --sign "$CODESIGN_IDENTITY" \ + "$DIST_DIR/remindctl" +fi + +chmod -R u+rw "$DIST_DIR" +xattr -cr "$DIST_DIR" +find "$DIST_DIR" -name '._*' -delete + +DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto} +( + cd "$DIST_DIR" + "$DITTO_BIN" --norsrc -c -k . "$ZIP_PATH" +) + +xcrun notarytool submit "$ZIP_PATH" \ + --key "$API_KEY_FILE" \ + --key-id "$APP_STORE_CONNECT_KEY_ID" \ + --issuer "$APP_STORE_CONNECT_ISSUER_ID" \ + --wait + +codesign --verify --strict --verbose=4 "$DIST_DIR/remindctl" +if ! spctl -a -t exec -vv "$DIST_DIR/remindctl"; then + echo "spctl check failed (CLI binaries often report 'not an app')." >&2 +fi + +echo "Done: $ZIP_PATH"