mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 21:37:17 +00:00
435 lines
17 KiB
YAML
435 lines
17 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://}"
|
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
# Keep self-hosted runners deterministic by wiping any prior workspace state.
|
|
git reset --hard || true
|
|
git clean -fdx || true
|
|
else
|
|
git init
|
|
fi
|
|
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 -f 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 "^\\| .*\\(https://github\\.com/h3pdesign/Neon-Vision-Editor/releases/tag/${TAG_NAME}\\) \\|" 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"
|
|
/usr/bin/codesign --verify --deep --strict "$WORK_DIR/extracted/Neon Vision Editor.app"
|
|
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
|
|
/usr/bin/codesign --verify --deep --strict "$MOUNT_POINT/Neon Vision Editor.app"
|
|
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
|