diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml new file mode 100644 index 0000000..471f5ec --- /dev/null +++ b/.github/workflows/finalize-release.yml @@ -0,0 +1,99 @@ +name: Finalize Release + +on: + workflow_run: + workflows: + - Release + types: + - completed + workflow_dispatch: + inputs: + release_run_id: + description: Release workflow run ID; leave blank to use the latest pending run + required: false + type: string + schedule: + - cron: "17,47 * * * *" + +permissions: + actions: read + contents: write + +concurrency: + group: meetingnotes-finalize-release + cancel-in-progress: false + +jobs: + finalize: + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: macos-15 + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + GH_TOKEN: ${{ github.token }} + PENDING_DIR: ${{ runner.temp }}/meetingnotes-pending + steps: + - uses: actions/checkout@v7 + + - name: Validate release secrets + run: | + for variable in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD SPARKLE_PRIVATE_KEY; do + if [[ -z "${!variable:-}" ]]; then + echo "Missing GitHub Actions secret: $variable" >&2 + exit 1 + fi + done + + - name: Download pending signed build + id: submission + env: + MANUAL_RUN_ID: ${{ inputs.release_run_id }} + COMPLETED_RUN_ID: ${{ github.event.workflow_run.id }} + run: | + candidate_ids=() + if [[ -n "${MANUAL_RUN_ID:-}" ]]; then + candidate_ids+=("$MANUAL_RUN_ID") + elif [[ -n "${COMPLETED_RUN_ID:-}" ]]; then + candidate_ids+=("$COMPLETED_RUN_ID") + else + while IFS= read -r run_id; do + candidate_ids+=("$run_id") + done < <(gh run list --repo "$GITHUB_REPOSITORY" --workflow Release --status success --limit 20 --json databaseId --jq '.[].databaseId') + fi + + for run_id in "${candidate_ids[@]}"; do + if [[ ! "$run_id" =~ ^[0-9]+$ ]]; then + echo "Invalid release run ID: $run_id" >&2 + exit 1 + fi + + artifact_name="meetingnotes-notarization-$run_id" + artifact_count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts" \ + --jq "[.artifacts[] | select(.name == \"$artifact_name\" and .expired == false)] | length") + if [[ "$artifact_count" == 0 ]]; then + continue + fi + + rm -rf "$PENDING_DIR" + mkdir -p "$PENDING_DIR" + gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name "$artifact_name" --dir "$PENDING_DIR" + + version=$(<"$PENDING_DIR/version") + if gh release view "v$version" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + continue + fi + + echo "found=true" >> "$GITHUB_OUTPUT" + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + echo "Using release submission from workflow run $run_id" + exit 0 + done + + echo "found=false" >> "$GITHUB_OUTPUT" + echo "No pending release submission was found" + + - name: Check notarization and publish when accepted + if: steps.submission.outputs.found == 'true' + run: scripts/finalize_release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfc0457..a034974 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: type: string permissions: - contents: write + contents: read concurrency: group: meetingnotes-release @@ -23,7 +23,6 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} steps: - uses: actions/checkout@v7 with: @@ -34,7 +33,7 @@ jobs: APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | - for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD SPARKLE_PRIVATE_KEY; do + for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD; do if [[ -z "${!variable:-}" ]]; then echo "Missing GitHub Actions secret: $variable" >&2 exit 1 @@ -56,11 +55,20 @@ jobs: fi echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV" - - name: Build, sign, notarize, and create appcast - timeout-minutes: 350 + - name: Build, sign, and submit for notarization + timeout-minutes: 30 run: scripts/package_release.sh + - name: Preserve signed build while Apple processes it + uses: actions/upload-artifact@v4 + with: + name: meetingnotes-notarization-${{ github.run_id }} + path: ${{ runner.temp }}/meetingnotes-release/pending + if-no-files-found: error + retention-days: 30 + - name: Create GitHub release + if: ${{ false }} env: GH_TOKEN: ${{ github.token }} run: | diff --git a/scripts/finalize_release.sh b/scripts/finalize_release.sh new file mode 100755 index 0000000..42dabdf --- /dev/null +++ b/scripts/finalize_release.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash + +set -euo pipefail + +APP_NAME="Meetingnotes" +RUNNER_TEMP="${RUNNER_TEMP:-/tmp}" +PENDING_DIR="${PENDING_DIR:-$RUNNER_TEMP/meetingnotes-pending}" +WORK_ROOT="${WORK_ROOT:-$RUNNER_TEMP/meetingnotes-finalize}" +APP_PATH="$WORK_ROOT/$APP_NAME.app" +RELEASE_DIR="$WORK_ROOT/release" +VERSION_PATH="$PENDING_DIR/version" +COMMIT_SHA_PATH="$PENDING_DIR/commit-sha" +SUBMISSION_PATH="$PENDING_DIR/notary-submission.json" +PRE_NOTARY_ZIP="$PENDING_DIR/$APP_NAME-pre-notary.zip" + +required_variables=( + APPLE_ID + APPLE_TEAM_ID + APPLE_APP_PASSWORD + SPARKLE_PRIVATE_KEY + GH_TOKEN + GITHUB_REPOSITORY +) + +for variable in "${required_variables[@]}"; do + if [[ -z "${!variable:-}" ]]; then + echo "Missing required environment variable: $variable" >&2 + exit 1 + fi +done + +for path in "$VERSION_PATH" "$COMMIT_SHA_PATH" "$SUBMISSION_PATH" "$PRE_NOTARY_ZIP" "$PENDING_DIR/generate_appcast"; do + if [[ ! -e "$path" ]]; then + echo "Missing release submission artifact: $path" >&2 + exit 1 + fi +done + +VERSION=$(<"$VERSION_PATH") +COMMIT_SHA=$(<"$COMMIT_SHA_PATH") +SUBMISSION_ID=$(plutil -extract id raw -o - "$SUBMISSION_PATH") +TAG="v$VERSION" + +if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release already exists: $TAG" + exit 0 +fi + +STATUS_PATH="$WORK_ROOT/notary-status.json" +rm -rf "$WORK_ROOT" +mkdir -p "$RELEASE_DIR" + +notary_info_succeeded=false +for attempt in 1 2 3; do + if xcrun notarytool info "$SUBMISSION_ID" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --output-format json > "$STATUS_PATH"; then + notary_info_succeeded=true + break + fi + echo "Notary status check failed ($attempt/3); retrying" + sleep 15 +done + +if [[ "$notary_info_succeeded" != true ]]; then + echo "Unable to query Apple notarization status after three attempts" >&2 + exit 1 +fi + +NOTARY_STATUS=$(plutil -extract status raw -o - "$STATUS_PATH") +echo "Notarization status for $SUBMISSION_ID: $NOTARY_STATUS" + +case "$NOTARY_STATUS" in + "In Progress") + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + printf 'Apple is still processing Meetingnotes %s. Submission: `%s`. The scheduled workflow will check again.\n' \ + "$VERSION" "$SUBMISSION_ID" >> "$GITHUB_STEP_SUMMARY" + fi + exit 0 + ;; + Accepted) + ;; + Invalid|Rejected) + xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_PASSWORD" || true + exit 1 + ;; + *) + echo "Unexpected notarization status: $NOTARY_STATUS" >&2 + exit 1 + ;; +esac + +ditto -x -k "$PRE_NOTARY_ZIP" "$WORK_ROOT" +if [[ ! -d "$APP_PATH" ]]; then + echo "Signed app was not found after extracting $PRE_NOTARY_ZIP" >&2 + exit 1 +fi + +APP_VERSION=$(plutil -extract CFBundleShortVersionString raw -o - "$APP_PATH/Contents/Info.plist") +if [[ "$APP_VERSION" != "$VERSION" ]]; then + echo "Signed app version $APP_VERSION does not match release version $VERSION" >&2 + exit 1 +fi + +xcrun stapler staple "$APP_PATH" +xcrun stapler validate "$APP_PATH" +spctl --assess --type execute --verbose=2 "$APP_PATH" + +ARCHIVE_NAME="$APP_NAME-$VERSION.zip" +ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME" +ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH" + +GENERATE_APPCAST="$PENDING_DIR/generate_appcast" +chmod +x "$GENERATE_APPCAST" +DOWNLOAD_URL="https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/" +printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \ + --ed-key-file - \ + --download-url-prefix "$DOWNLOAD_URL" \ + --maximum-deltas 0 \ + -o "$RELEASE_DIR/appcast.xml" + +grep -q "$DOWNLOAD_URL$ARCHIVE_NAME" "$RELEASE_DIR/appcast.xml" +grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml" + +gh release \ + create "$TAG" \ + "$ARCHIVE_PATH" \ + "$RELEASE_DIR/appcast.xml" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$COMMIT_SHA" \ + --title "Meetingnotes $VERSION" \ + --generate-notes + +echo "Published Meetingnotes $VERSION" diff --git a/scripts/package_release.sh b/scripts/package_release.sh index faddaa9..ff8ab77 100755 --- a/scripts/package_release.sh +++ b/scripts/package_release.sh @@ -8,7 +8,7 @@ SCHEME="meetingnotes" RUNNER_TEMP="${RUNNER_TEMP:-/tmp}" BUILD_ROOT="${BUILD_ROOT:-$RUNNER_TEMP/meetingnotes-release}" DERIVED_DATA="$BUILD_ROOT/DerivedData" -RELEASE_DIR="$BUILD_ROOT/release" +PENDING_DIR="$BUILD_ROOT/pending" APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app" required_variables=( @@ -17,8 +17,7 @@ required_variables=( APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD - SPARKLE_PRIVATE_KEY - GITHUB_REPOSITORY + GITHUB_SHA ) for variable in "${required_variables[@]}"; do @@ -35,7 +34,7 @@ if [[ "$project_version" != "$VERSION" ]]; then fi rm -rf "$BUILD_ROOT" -mkdir -p "$RELEASE_DIR" +mkdir -p "$PENDING_DIR" xcodebuild \ -project "$PROJECT" \ @@ -79,77 +78,31 @@ codesign --force --timestamp --options runtime \ codesign --verify --deep --strict --verbose=2 "$APP_PATH" codesign -d --entitlements :- "$APP_PATH" 2>&1 | grep -q 'com.apple.security.app-sandbox' -PRE_NOTARY_ZIP="$BUILD_ROOT/$APP_NAME-pre-notary.zip" +PRE_NOTARY_ZIP="$PENDING_DIR/$APP_NAME-pre-notary.zip" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$PRE_NOTARY_ZIP" xcrun notarytool submit "$PRE_NOTARY_ZIP" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_APP_PASSWORD" \ - --output-format json > "$BUILD_ROOT/notary-submission.json" + --output-format json > "$PENDING_DIR/notary-submission.json" -SUBMISSION_ID=$(plutil -extract id raw -o - "$BUILD_ROOT/notary-submission.json") +SUBMISSION_ID=$(plutil -extract id raw -o - "$PENDING_DIR/notary-submission.json") echo "Notarization submitted: $SUBMISSION_ID" -NOTARY_STATUS="In Progress" -NOTARY_MAX_ATTEMPTS=660 -for attempt in $(seq 1 "$NOTARY_MAX_ATTEMPTS"); do - xcrun notarytool info "$SUBMISSION_ID" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_PASSWORD" \ - --output-format json > "$BUILD_ROOT/notary-status.json" - NOTARY_STATUS=$(plutil -extract status raw -o - "$BUILD_ROOT/notary-status.json") - echo "Notarization status ($attempt/$NOTARY_MAX_ATTEMPTS): $NOTARY_STATUS" - - case "$NOTARY_STATUS" in - Accepted) - break - ;; - Invalid|Rejected) - xcrun notarytool log "$SUBMISSION_ID" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_PASSWORD" || true - exit 1 - ;; - "In Progress") - sleep 30 - ;; - *) - echo "Unexpected notarization status: $NOTARY_STATUS" >&2 - exit 1 - ;; - esac -done - -if [[ "$NOTARY_STATUS" != "Accepted" ]]; then - echo "Notarization did not finish within 5.5 hours: $SUBMISSION_ID" >&2 - exit 1 -fi - -xcrun stapler staple "$APP_PATH" -xcrun stapler validate "$APP_PATH" - -ARCHIVE_NAME="$APP_NAME-$VERSION.zip" -ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME" -ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH" -spctl --assess --type execute --verbose=2 "$APP_PATH" - GENERATE_APPCAST=$(find "$DERIVED_DATA/SourcePackages/artifacts" -type f -name generate_appcast -print -quit) if [[ -z "$GENERATE_APPCAST" ]]; then echo "Sparkle generate_appcast tool was not found" >&2 exit 1 fi +cp "$GENERATE_APPCAST" "$PENDING_DIR/generate_appcast" -DOWNLOAD_URL="https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/" -printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \ - --ed-key-file - \ - --download-url-prefix "$DOWNLOAD_URL" \ - --maximum-deltas 0 \ - -o "$RELEASE_DIR/appcast.xml" +printf '%s' "$VERSION" > "$PENDING_DIR/version" +printf '%s' "$GITHUB_SHA" > "$PENDING_DIR/commit-sha" -grep -q "$DOWNLOAD_URL$ARCHIVE_NAME" "$RELEASE_DIR/appcast.xml" -grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml" +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + printf 'Submitted Meetingnotes %s for Apple notarization.\n\nSubmission: `%s`\n\nThe finalize workflow will publish the release after Apple accepts it.\n' \ + "$VERSION" "$SUBMISSION_ID" >> "$GITHUB_STEP_SUMMARY" +fi -echo "Release artifacts are ready in $RELEASE_DIR" +echo "Signed app and notarization metadata are ready in $PENDING_DIR"