Neon-Vision-Editor/scripts/workflow-templates/release-notarized-selfhosted.yml

427 lines
16 KiB
YAML

name: Release macOS app (notarized, self-hosted)
run-name: notarized release (self-hosted) ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: "Existing Git tag to release (e.g. v0.4.6)"
required: true
type: string
use_self_hosted:
description: "Allow self-hosted runner usage (requires trusted release context)"
required: true
default: false
type: boolean
permissions:
actions: read
contents: write
issues: write
jobs:
release:
if: ${{ inputs.use_self_hosted == true }}
timeout-minutes: 180
runs-on: [self-hosted, macOS]
environment: self-hosted-release
concurrency:
group: release-${{ inputs.tag }}
cancel-in-progress: true
steps:
- name: Validate trusted self-hosted request
env:
TAG_NAME: ${{ inputs.tag }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
SERVER_HOST="${SERVER_URL#https://}"
git init
REMOTE_URL="https://x-access-token:${GH_TOKEN}@${SERVER_HOST}/${REPO}.git"
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "$REMOTE_URL"
else
git remote add origin "$REMOTE_URL"
fi
git fetch --force --depth=1 origin "+refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}"
git fetch --force --depth=1 origin "+refs/heads/main:refs/remotes/origin/main"
TAG_SHA="$(git rev-list -n1 "refs/tags/${TAG_NAME}")"
MAIN_SHA="$(git rev-list -n1 "refs/remotes/origin/main")"
echo "Tag SHA: ${TAG_SHA}"
echo "Main SHA: ${MAIN_SHA}"
if [[ "${TAG_SHA}" != "${MAIN_SHA}" ]]; then
echo "Self-hosted releases are only allowed for tags that point to origin/main HEAD." >&2
exit 1
fi
- name: Checkout tag
env:
TAG_NAME: ${{ inputs.tag }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
SERVER_HOST="${SERVER_URL#https://}"
git init
REMOTE_URL="https://x-access-token:${GH_TOKEN}@${SERVER_HOST}/${REPO}.git"
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "$REMOTE_URL"
else
git remote add origin "$REMOTE_URL"
fi
git fetch --depth=1 origin "refs/tags/${TAG_NAME}"
git checkout FETCH_HEAD
- name: Normalize shell script line endings
run: |
set -euo pipefail
find scripts -type f -name "*.sh" -exec perl -pi -e 's/\r$//' {} +
chmod +x scripts/ci/select_xcode17.sh
- name: Select/verify Xcode 17+
run: |
set -euo pipefail
if [[ ! -x scripts/ci/select_xcode17.sh ]]; then
echo "Missing scripts/ci/select_xcode17.sh in checked-out tag. Production notarized release requires Xcode 17+ selection." >&2
exit 1
fi
bash scripts/ci/select_xcode17.sh
- name: Import signing certificate
env:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
ORIGINAL_LIST="$RUNNER_TEMP/original-user-keychains.txt"
ORIGINAL_DEFAULT="$RUNNER_TEMP/original-default-keychain.txt"
ORIGINAL_LOGIN="$RUNNER_TEMP/original-login-keychain.txt"
echo "$MACOS_CERT_P12" | base64 --decode > cert.p12
security list-keychains -d user > "$ORIGINAL_LIST" || true
security default-keychain -d user > "$ORIGINAL_DEFAULT" || true
security login-keychain -d user > "$ORIGINAL_LOGIN" || true
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import cert.p12 -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign
EXISTING_KEYCHAINS=()
while IFS= read -r keychain_path; do
[[ -n "$keychain_path" ]] && EXISTING_KEYCHAINS+=("$keychain_path")
done < <(security list-keychains -d user | sed -E 's/^[[:space:]]*"?(.*)"?[[:space:]]*$/\1/' | awk 'NF')
if (( ${#EXISTING_KEYCHAINS[@]} > 0 )); then
security list-keychains -d user -s "$KEYCHAIN" "${EXISTING_KEYCHAINS[@]}"
else
security list-keychains -d user -s "$KEYCHAIN"
fi
- name: Build archive
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ARCHIVE_PATH: ${{ runner.temp }}/NeonVisionEditor.xcarchive
run: |
set -euo pipefail
xcodebuild \
-project "Neon Vision Editor.xcodeproj" \
-scheme "Neon Vision Editor" \
-configuration Release \
-destination "generic/platform=macOS" \
-archivePath "$ARCHIVE_PATH" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Developer ID Application" \
archive
- name: Export app
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ARCHIVE_PATH: ${{ runner.temp }}/NeonVisionEditor.xcarchive
EXPORT_PATH: ${{ runner.temp }}/export
run: |
set -euo pipefail
mkdir -p "$EXPORT_PATH"
cat > export.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>method</key><string>developer-id</string>
<key>teamID</key><string>${TEAM_ID}</string>
</dict>
</plist>
EOF
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist export.plist
- name: Verify app icon payload
env:
EXPORT_PATH: ${{ runner.temp }}/export
REQUIRE_ICONSTACK: "1"
run: |
set -euo pipefail
APP="$EXPORT_PATH/Neon Vision Editor.app"
scripts/ci/verify_icon_payload.sh "$APP"
- name: Notarize
env:
EXPORT_PATH: ${{ runner.temp }}/export
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
set -euo pipefail
APP="$EXPORT_PATH/Neon Vision Editor.app"
ditto -c -k --keepParent "$APP" submit.zip
xcrun notarytool submit submit.zip \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--wait
xcrun stapler staple "$APP"
- name: Zip notarized app
env:
EXPORT_PATH: ${{ runner.temp }}/export
run: |
set -euo pipefail
ditto -c -k --keepParent "$EXPORT_PATH/Neon Vision Editor.app" Neon.Vision.Editor.app.zip
- name: Create DMG release asset
env:
EXPORT_PATH: ${{ runner.temp }}/export
run: |
set -euo pipefail
DMG_STAGE="$(mktemp -d)"
cp -R "$EXPORT_PATH/Neon Vision Editor.app" "$DMG_STAGE/"
ln -s /Applications "$DMG_STAGE/Applications"
hdiutil create \
-volname "Neon Vision Editor" \
-srcfolder "$DMG_STAGE" \
-ov \
-format UDZO \
Neon.Vision.Editor.app.dmg
rm -rf "$DMG_STAGE"
- name: Extract changelog section
env:
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
./scripts/extract_changelog_section.sh CHANGELOG.md "$TAG_NAME" > release-notes.md
- name: Validate release docs are in sync
env:
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
if grep -nE "^- TODO\r?$" release-notes.md >/dev/null; then
echo "CHANGELOG section for ${TAG_NAME} still contains TODO entries." >&2
exit 1
fi
grep -nE "^> Latest release: \\*\\*${TAG_NAME}\\*\\*\r?$" README.md >/dev/null
grep -nE "^- Latest release: \\*\\*${TAG_NAME}\\*\\*\r?$" README.md >/dev/null
grep -nE "^### ${TAG_NAME} \\(summary\\)\r?$" README.md >/dev/null
- name: Create or Update GitHub Release
id: publish_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
gh release upload "$TAG_NAME" Neon.Vision.Editor.app.zip Neon.Vision.Editor.app.dmg --clobber
gh release edit "$TAG_NAME" --title "Neon Vision Editor $TAG_NAME" --notes-file release-notes.md
else
gh release create "$TAG_NAME" Neon.Vision.Editor.app.zip Neon.Vision.Editor.app.dmg \
--title "Neon Vision Editor $TAG_NAME" \
--notes-file release-notes.md
fi
- name: Verify published release asset payload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
REQUIRE_ICONSTACK: "1"
run: |
set -euo pipefail
WORK_DIR="$(mktemp -d)"
MOUNT_POINT=""
cleanup_mount() {
if [[ -n "${MOUNT_POINT}" ]]; then
hdiutil detach "${MOUNT_POINT}" -quiet || true
fi
rm -rf "$WORK_DIR"
}
trap cleanup_mount EXIT
gh release download "$TAG_NAME" -p Neon.Vision.Editor.app.zip -p Neon.Vision.Editor.app.dmg -D "$WORK_DIR"
ditto -x -k "$WORK_DIR/Neon.Vision.Editor.app.zip" "$WORK_DIR/extracted"
scripts/ci/verify_icon_payload.sh "$WORK_DIR/extracted/Neon Vision Editor.app"
MOUNT_POINT="$WORK_DIR/dmg-mount"
mkdir -p "$MOUNT_POINT"
hdiutil attach "$WORK_DIR/Neon.Vision.Editor.app.dmg" -nobrowse -mountpoint "$MOUNT_POINT" -quiet
if [[ ! -d "$MOUNT_POINT/Neon Vision Editor.app" ]]; then
echo "Mounted DMG does not contain app bundle." >&2
exit 1
fi
scripts/ci/verify_icon_payload.sh "$MOUNT_POINT/Neon Vision Editor.app"
- name: Roll back broken published release asset
if: ${{ failure() && steps.publish_release.outcome == 'success' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
gh release delete-asset "$TAG_NAME" "Neon.Vision.Editor.app.zip" -y || true
gh release delete-asset "$TAG_NAME" "Neon.Vision.Editor.app.dmg" -y || true
gh release edit "$TAG_NAME" --draft true || true
echo "Rolled back release asset and marked release as draft due to post-publish verification failure."
- name: Create release-failure alert issue
if: ${{ failure() }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
gh issue create \
--title "Release failure: ${TAG_NAME} (run ${GITHUB_RUN_ID})" \
--body "Automated release failed for ${TAG_NAME}. Run URL: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--label "release-alert"
- name: Trigger homebrew-tap update
env:
GH_TOKEN: ${{ secrets.TAP_BOT_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
echo "Waiting for release API consistency for ${TAG_NAME}..."
for attempt in {1..12}; do
if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG_NAME}" >/dev/null 2>&1; then
echo "Release tag ${TAG_NAME} is visible (attempt ${attempt})."
break
fi
if [[ "$attempt" -eq 12 ]]; then
echo "Release tag ${TAG_NAME} still not visible after retries; dispatching anyway." >&2
break
fi
sleep 5
done
dispatch_homebrew() {
gh api repos/h3pdesign/homebrew-tap/dispatches \
-f event_type=notarized_release \
-f client_payload[tag]="$TAG_NAME"
}
wait_for_tap_run() {
local start_ts="$1"
local run_id=""
for poll in {1..24}; do
run_id="$(gh run list -R h3pdesign/homebrew-tap \
--workflow update-cask.yml \
--event repository_dispatch \
--limit 20 \
--json databaseId,displayTitle,createdAt \
--jq ".[] | select(.displayTitle == \"notarized_release\") | select(.createdAt >= \"${start_ts}\") | .databaseId" | head -n1 || true)"
if [[ -n "${run_id}" ]]; then
echo "${run_id}"
return 0
fi
sleep 5
done
return 1
}
wait_for_tap_completion() {
local run_id="$1"
gh run watch -R h3pdesign/homebrew-tap "${run_id}" || true
local conclusion
conclusion="$(gh run view -R h3pdesign/homebrew-tap "${run_id}" --json conclusion --jq .conclusion || true)"
if [[ "${conclusion}" == "success" ]]; then
return 0
fi
echo "homebrew-tap run ${run_id} concluded with '${conclusion:-unknown}'." >&2
return 1
}
for dispatch_attempt in {1..3}; do
dispatch_start="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
if ! dispatch_homebrew; then
echo "homebrew-tap dispatch failed on attempt ${dispatch_attempt}." >&2
sleep $((dispatch_attempt * 5))
continue
fi
echo "homebrew-tap dispatch accepted (attempt ${dispatch_attempt})."
run_id="$(wait_for_tap_run "${dispatch_start}" || true)"
if [[ -z "${run_id}" ]]; then
echo "No homebrew-tap run appeared after dispatch attempt ${dispatch_attempt}." >&2
sleep $((dispatch_attempt * 5))
continue
fi
if wait_for_tap_completion "${run_id}"; then
echo "homebrew-tap run ${run_id} succeeded."
exit 0
fi
if [[ "${dispatch_attempt}" -lt 3 ]]; then
sleep $((dispatch_attempt * 10))
fi
done
echo "Failed to complete homebrew-tap update after retries." >&2
exit 1
- name: Restore user keychain state
if: ${{ always() }}
run: |
set -euo pipefail
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
ORIGINAL_LIST="$RUNNER_TEMP/original-user-keychains.txt"
ORIGINAL_DEFAULT="$RUNNER_TEMP/original-default-keychain.txt"
ORIGINAL_LOGIN="$RUNNER_TEMP/original-login-keychain.txt"
if [[ -f "$ORIGINAL_LIST" ]]; then
RESTORE_LIST=()
while IFS= read -r keychain_path; do
[[ -n "$keychain_path" ]] && RESTORE_LIST+=("$keychain_path")
done < <(sed -E 's/^[[:space:]]*"?(.*)"?[[:space:]]*$/\1/' "$ORIGINAL_LIST" | awk 'NF')
if (( ${#RESTORE_LIST[@]} > 0 )); then
security list-keychains -d user -s "${RESTORE_LIST[@]}" || true
fi
fi
if [[ -f "$ORIGINAL_DEFAULT" ]]; then
DEFAULT_KEYCHAIN="$(sed -E 's/^[[:space:]]*"?(.*)"?[[:space:]]*$/\1/' "$ORIGINAL_DEFAULT" | head -n1)"
if [[ -n "${DEFAULT_KEYCHAIN:-}" ]]; then
security default-keychain -d user -s "$DEFAULT_KEYCHAIN" || true
fi
fi
if [[ -f "$ORIGINAL_LOGIN" ]]; then
LOGIN_KEYCHAIN="$(sed -E 's/^[[:space:]]*"?(.*)"?[[:space:]]*$/\1/' "$ORIGINAL_LOGIN" | head -n1)"
if [[ -n "${LOGIN_KEYCHAIN:-}" ]]; then
security login-keychain -d user -s "$LOGIN_KEYCHAIN" || true
fi
fi
security delete-keychain "$KEYCHAIN" || true
rm -f cert.p12