ci(release): harden notarized pipeline and automate preflight

This commit is contained in:
h3p 2026-02-13 00:58:32 +01:00
parent 3efb496f80
commit b2420e73b3
19 changed files with 655 additions and 159 deletions

65
.github/workflows/pre-release-ci.yml vendored Normal file
View file

@ -0,0 +1,65 @@
name: Pre-release CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
preflight:
runs-on: macos-15
steps:
- name: Checkout repository
env:
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REF: ${{ github.sha }}
run: |
set -euo pipefail
git init
git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git"
git fetch --depth=1 origin "$REF"
git checkout FETCH_HEAD
- name: Select/verify Xcode 17+
run: |
set -euo pipefail
scripts/ci/select_xcode17.sh
- name: Validate basic release docs format
run: |
set -euo pipefail
if grep -nE "^- TODO$" CHANGELOG.md >/dev/null; then
echo "CHANGELOG contains TODO entries. Fill release notes before merging." >&2
exit 1
fi
grep -nE "^> Latest release: \\*\\*v[0-9]+\\.[0-9]+\\.[0-9]+(-[A-Za-z0-9.]+)?\\*\\*$" README.md >/dev/null
grep -nE "^- Latest release: \\*\\*v[0-9]+\\.[0-9]+\\.[0-9]+(-[A-Za-z0-9.]+)?\\*\\*$" README.md >/dev/null
- name: Run critical runtime tests
env:
DERIVED_DATA: ${{ runner.temp }}/DerivedData
run: |
set -euo pipefail
xcodebuild \
-project "Neon Vision Editor.xcodeproj" \
-scheme "Neon Vision Editor" \
-destination "platform=macOS" \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
-only-testing:"Neon Vision EditorTests/ReleaseRuntimePolicyTests" \
test
- name: Verify icon payload in built app
env:
DERIVED_DATA: ${{ runner.temp }}/DerivedData
run: |
set -euo pipefail
APP="$DERIVED_DATA/Build/Products/Debug/Neon Vision Editor.app"
scripts/ci/verify_icon_payload.sh "$APP"

35
.github/workflows/release-dry-run.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Release dry-run
on:
workflow_dispatch:
inputs:
tag:
description: "Tag/version to validate (e.g. v0.4.9)"
required: true
type: string
permissions:
contents: read
jobs:
dry_run:
runs-on: macos-15
steps:
- name: Checkout repository
env:
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REF: ${{ github.sha }}
run: |
set -euo pipefail
git init
git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git"
git fetch --depth=1 origin "$REF"
git checkout FETCH_HEAD
- name: Run release preflight
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
scripts/ci/release_preflight.sh "$TAG"

View file

@ -60,15 +60,10 @@ jobs:
git fetch --depth=1 origin "refs/tags/${TAG_NAME}"
git checkout FETCH_HEAD
- name: Verify Xcode version
- name: Select/verify Xcode 17+
run: |
set -euo pipefail
xcodebuild -version
XCODE_MAJOR="$(xcodebuild -version | awk '/Xcode/ {split($2, v, "."); print v[1]}')"
if [[ "${XCODE_MAJOR:-0}" -lt 17 ]]; then
echo "Xcode 17+ required for AppIcon.icon (dark/light icon composer assets)." >&2
exit 1
fi
scripts/ci/select_xcode17.sh
- name: Import signing certificate
env:
@ -133,25 +128,7 @@ jobs:
run: |
set -euo pipefail
APP="$EXPORT_PATH/Neon Vision Editor.app"
INFO="$APP/Contents/Info.plist"
CAR="$APP/Contents/Resources/Assets.car"
ICON_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$INFO" 2>/dev/null || true)"
if [[ "$ICON_NAME" != "AppIcon" ]]; then
echo "Unexpected CFBundleIconName: '$ICON_NAME' (expected 'AppIcon')." >&2
exit 1
fi
xcrun --sdk macosx assetutil --info "$CAR" > icon-info.json
if ! grep -Eq '"RenditionName" : "AppIcon\.iconstack"' icon-info.json; then
echo "Missing AppIcon iconstack rendition in Assets.car." >&2
exit 1
fi
if ! grep -Eq '"Name" : "AppIcon"' icon-info.json; then
echo "Missing AppIcon image renditions in Assets.car." >&2
exit 1
fi
echo "Icon payload preflight passed."
scripts/ci/verify_icon_payload.sh "$APP"
- name: Notarize
env:
@ -201,6 +178,7 @@ jobs:
grep -nE "^### ${TAG_NAME} \\(summary\\)$" README.md >/dev/null
- name: Create or Update GitHub Release
id: publish_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
@ -216,6 +194,38 @@ jobs:
--notes-file release-notes.md
fi
- name: Verify published release asset payload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
scripts/ci/verify_release_asset.sh "$TAG_NAME"
- 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 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 }}

View file

@ -17,7 +17,7 @@ jobs:
concurrency:
group: release-${{ inputs.tag }}
cancel-in-progress: true
runs-on: macos-latest
runs-on: macos-15
steps:
- name: Checkout tag
@ -32,15 +32,10 @@ jobs:
git fetch --depth=1 origin "refs/tags/${TAG_NAME}"
git checkout FETCH_HEAD
- name: Verify Xcode version
- name: Select/verify Xcode 17+
run: |
set -euo pipefail
xcodebuild -version
XCODE_MAJOR="$(xcodebuild -version | awk '/Xcode/ {split($2, v, "."); print v[1]}')"
if [[ "${XCODE_MAJOR:-0}" -lt 17 ]]; then
echo "Xcode 17+ required for AppIcon.icon (dark/light icon composer assets)." >&2
exit 1
fi
scripts/ci/select_xcode17.sh
- name: Import signing certificate
env:
@ -105,25 +100,7 @@ jobs:
run: |
set -euo pipefail
APP="$EXPORT_PATH/Neon Vision Editor.app"
INFO="$APP/Contents/Info.plist"
CAR="$APP/Contents/Resources/Assets.car"
ICON_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$INFO" 2>/dev/null || true)"
if [[ "$ICON_NAME" != "AppIcon" ]]; then
echo "Unexpected CFBundleIconName: '$ICON_NAME' (expected 'AppIcon')." >&2
exit 1
fi
xcrun --sdk macosx assetutil --info "$CAR" > icon-info.json
if ! grep -Eq '"RenditionName" : "AppIcon\.iconstack"' icon-info.json; then
echo "Missing AppIcon iconstack rendition in Assets.car." >&2
exit 1
fi
if ! grep -Eq '"Name" : "AppIcon"' icon-info.json; then
echo "Missing AppIcon image renditions in Assets.car." >&2
exit 1
fi
echo "Icon payload preflight passed."
scripts/ci/verify_icon_payload.sh "$APP"
- name: Notarize
env:
@ -173,6 +150,7 @@ jobs:
grep -nE "^### ${TAG_NAME} \\(summary\\)$" README.md >/dev/null
- name: Create or Update GitHub Release
id: publish_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
@ -188,6 +166,38 @@ jobs:
--notes-file release-notes.md
fi
- name: Verify published release asset payload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
scripts/ci/verify_release_asset.sh "$TAG_NAME"
- 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 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 }}

View file

@ -1,26 +1,30 @@
name: Release macOS app (unsigned)
name: Release macOS app (unsigned dry-run)
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Existing Git tag to validate (e.g. v0.4.8)"
required: true
type: string
permissions:
contents: write
contents: read
jobs:
release:
concurrency:
group: release-${{ github.ref_name }}
group: unsigned-dry-run-${{ inputs.tag }}
cancel-in-progress: true
runs-on: macos-latest
runs-on: macos-15
steps:
- name: Checkout (no external actions)
env:
TAG_NAME: ${{ github.ref_name }}
TAG_NAME: ${{ inputs.tag }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git init
git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git"
git fetch --depth=1 origin "refs/tags/${TAG_NAME}"
@ -29,12 +33,7 @@ jobs:
- name: Show Xcode Version
run: |
set -euo pipefail
xcodebuild -version
XCODE_MAJOR="$(xcodebuild -version | awk '/Xcode/ {split($2, v, "."); print v[1]}')"
if [[ "${XCODE_MAJOR:-0}" -lt 17 ]]; then
echo "Xcode 17+ required for AppIcon.icon (dark/light icon composer assets)." >&2
exit 1
fi
scripts/ci/select_xcode17.sh
- name: Build macOS archive
env:
@ -60,25 +59,7 @@ jobs:
run: |
set -euo pipefail
APP="$ARCHIVE_PATH/Products/Applications/Neon Vision Editor.app"
INFO="$APP/Contents/Info.plist"
CAR="$APP/Contents/Resources/Assets.car"
ICON_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$INFO" 2>/dev/null || true)"
if [[ "$ICON_NAME" != "AppIcon" ]]; then
echo "Unexpected CFBundleIconName: '$ICON_NAME' (expected 'AppIcon')." >&2
exit 1
fi
xcrun --sdk macosx assetutil --info "$CAR" > icon-info.json
if ! grep -Eq '"RenditionName" : "AppIcon\.iconstack"' icon-info.json; then
echo "Missing AppIcon iconstack rendition in Assets.car." >&2
exit 1
fi
if ! grep -Eq '"Name" : "AppIcon"' icon-info.json; then
echo "Missing AppIcon image renditions in Assets.car." >&2
exit 1
fi
echo "Icon payload preflight passed."
scripts/ci/verify_icon_payload.sh "$APP"
- name: Zip app
env:
@ -94,14 +75,14 @@ jobs:
- name: Extract changelog section
env:
TAG_NAME: ${{ github.ref_name }}
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: ${{ github.ref_name }}
TAG_NAME: ${{ inputs.tag }}
run: |
set -euo pipefail
if grep -nE "^- TODO$" release-notes.md >/dev/null; then
@ -112,16 +93,9 @@ jobs:
grep -nE "^- Latest release: \\*\\*${TAG_NAME}\\*\\*$" README.md >/dev/null
grep -nE "^### ${TAG_NAME} \\(summary\\)$" README.md >/dev/null
- name: Create or Update GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
- name: Upload unsigned ZIP as workflow artifact
run: |
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo "Release ${TAG_NAME} already exists; skipping unsigned asset upload to avoid clobbering notarized artifacts."
else
gh release create "$TAG_NAME" \
--title "Neon Vision Editor $TAG_NAME" \
--notes-file release-notes.md \
"Neon.Vision.Editor.app.zip"
fi
set -euo pipefail
mkdir -p unsigned-artifacts
cp Neon.Vision.Editor.app.zip unsigned-artifacts/
echo "Unsigned dry-run artifact prepared; this workflow never publishes GitHub release assets."

View file

@ -358,7 +358,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 188;
CURRENT_PROJECT_VERSION = 189;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -438,7 +438,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 188;
CURRENT_PROJECT_VERSION = 189;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;

View file

@ -29,6 +29,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "50864E55167D4016920786DF"
BuildableName = "Neon Vision EditorTests.xctest"
BlueprintName = "Neon Vision EditorTests"
ReferencedContainer = "container:Neon Vision Editor.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"

View file

@ -99,14 +99,7 @@ struct NeonVisionEditorApp: App {
#endif
private var preferredAppearance: ColorScheme? {
switch appearance {
case "light":
return .light
case "dark":
return .dark
default:
return nil
}
ReleaseRuntimePolicy.preferredColorScheme(for: appearance)
}
#if os(macOS)

View file

@ -0,0 +1,61 @@
import Foundation
import SwiftUI
enum ReleaseRuntimePolicy {
static func settingsTab(from requested: String?) -> String {
let trimmed = requested?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "general" : trimmed
}
static func preferredColorScheme(for appearance: String) -> ColorScheme? {
switch appearance {
case "light":
return .light
case "dark":
return .dark
default:
return nil
}
}
static func nextFindMatch(
in source: String,
query: String,
useRegex: Bool,
caseSensitive: Bool,
cursorLocation: Int
) -> (range: NSRange, nextCursorLocation: Int)? {
guard !query.isEmpty else { return nil }
let ns = source as NSString
let clampedStart = min(max(0, cursorLocation), ns.length)
let forwardRange = NSRange(location: clampedStart, length: max(0, ns.length - clampedStart))
let wrapRange = NSRange(location: 0, length: max(0, clampedStart))
let match: NSRange?
if useRegex {
guard let regex = try? NSRegularExpression(
pattern: query,
options: caseSensitive ? [] : [.caseInsensitive]
) else {
return nil
}
match = regex.firstMatch(in: source, options: [], range: forwardRange)?.range
?? regex.firstMatch(in: source, options: [], range: wrapRange)?.range
} else {
let options: NSString.CompareOptions = caseSensitive ? [] : [.caseInsensitive]
match = ns.range(of: query, options: options, range: forwardRange).toOptional()
?? ns.range(of: query, options: options, range: wrapRange).toOptional()
}
guard let found = match else { return nil }
return (range: found, nextCursorLocation: found.upperBound)
}
static func subscriptionButtonsEnabled(
canUseInAppPurchases: Bool,
isPurchasing: Bool,
isLoadingProducts: Bool
) -> Bool {
canUseInAppPurchases && !isPurchasing && !isLoadingProducts
}
}

View file

@ -8,7 +8,7 @@ import UIKit
extension ContentView {
func openSettings(tab: String? = nil) {
settingsActiveTab = tab ?? "general"
settingsActiveTab = ReleaseRuntimePolicy.settingsTab(from: tab)
#if os(macOS)
if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) {
_ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
@ -202,43 +202,34 @@ extension ContentView {
guard !findQuery.isEmpty else { return }
findStatusMessage = ""
let source = currentContentBinding.wrappedValue
let ns = source as NSString
let fingerprint = "\(findQuery)|\(findUsesRegex)|\(findCaseSensitive)"
if fingerprint != iOSLastFindFingerprint {
iOSLastFindFingerprint = fingerprint
iOSFindCursorLocation = 0
}
let clampedStart = min(max(0, iOSFindCursorLocation), ns.length)
let forwardRange = NSRange(location: clampedStart, length: max(0, ns.length - clampedStart))
let wrapRange = NSRange(location: 0, length: max(0, clampedStart))
let foundRange: NSRange?
if findUsesRegex {
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
guard let next = ReleaseRuntimePolicy.nextFindMatch(
in: source,
query: findQuery,
useRegex: findUsesRegex,
caseSensitive: findCaseSensitive,
cursorLocation: iOSFindCursorLocation
) else {
if findUsesRegex, (try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive])) == nil {
findStatusMessage = "Invalid regex pattern"
return
}
foundRange = regex.firstMatch(in: source, options: [], range: forwardRange)?.range
?? regex.firstMatch(in: source, options: [], range: wrapRange)?.range
} else {
let opts: NSString.CompareOptions = findCaseSensitive ? [] : [.caseInsensitive]
foundRange = ns.range(of: findQuery, options: opts, range: forwardRange).toOptional()
?? ns.range(of: findQuery, options: opts, range: wrapRange).toOptional()
}
guard let match = foundRange else {
findStatusMessage = "No matches found"
return
}
iOSFindCursorLocation = match.upperBound
iOSFindCursorLocation = next.nextCursorLocation
NotificationCenter.default.post(
name: .moveCursorToRange,
object: nil,
userInfo: [
EditorCommandUserInfo.rangeLocation: match.location,
EditorCommandUserInfo.rangeLength: match.length
EditorCommandUserInfo.rangeLocation: next.range.location,
EditorCommandUserInfo.rangeLength: next.range.length
]
)
#endif

View file

@ -194,14 +194,7 @@ struct NeonSettingsView: View {
}
private var preferredColorSchemeOverride: ColorScheme? {
switch appearance {
case "light":
return .light
case "dark":
return .dark
default:
return nil
}
ReleaseRuntimePolicy.preferredColorScheme(for: appearance)
}
#if os(macOS)

View file

@ -0,0 +1,95 @@
import XCTest
import SwiftUI
@testable import Neon_Vision_Editor
final class ReleaseRuntimePolicyTests: XCTestCase {
func testSettingsTabFallsBackToGeneral() {
XCTAssertEqual(ReleaseRuntimePolicy.settingsTab(from: nil), "general")
XCTAssertEqual(ReleaseRuntimePolicy.settingsTab(from: ""), "general")
XCTAssertEqual(ReleaseRuntimePolicy.settingsTab(from: " "), "general")
XCTAssertEqual(ReleaseRuntimePolicy.settingsTab(from: "ai"), "ai")
}
func testPreferredColorSchemeMapping() {
XCTAssertEqual(ReleaseRuntimePolicy.preferredColorScheme(for: "light"), .light)
XCTAssertEqual(ReleaseRuntimePolicy.preferredColorScheme(for: "dark"), .dark)
XCTAssertNil(ReleaseRuntimePolicy.preferredColorScheme(for: "system"))
XCTAssertNil(ReleaseRuntimePolicy.preferredColorScheme(for: "unknown"))
}
func testFindNextMovesCursorForwardAndWraps() {
let text = "alpha beta alpha"
let first = ReleaseRuntimePolicy.nextFindMatch(
in: text,
query: "alpha",
useRegex: false,
caseSensitive: true,
cursorLocation: 0
)
XCTAssertEqual(first?.range.location, 0)
XCTAssertEqual(first?.nextCursorLocation, 5)
let second = ReleaseRuntimePolicy.nextFindMatch(
in: text,
query: "alpha",
useRegex: false,
caseSensitive: true,
cursorLocation: first?.nextCursorLocation ?? 0
)
XCTAssertEqual(second?.range.location, 11)
XCTAssertEqual(second?.nextCursorLocation, 16)
let wrapped = ReleaseRuntimePolicy.nextFindMatch(
in: text,
query: "alpha",
useRegex: false,
caseSensitive: true,
cursorLocation: second?.nextCursorLocation ?? 0
)
XCTAssertEqual(wrapped?.range.location, 0)
}
func testFindNextRegexSearch() {
let text = "id-12 id-345"
let match = ReleaseRuntimePolicy.nextFindMatch(
in: text,
query: "id-[0-9]+",
useRegex: true,
caseSensitive: true,
cursorLocation: 0
)
XCTAssertEqual(match?.range.location, 0)
XCTAssertEqual(match?.range.length, 5)
}
func testSubscriptionButtonEnablement() {
XCTAssertTrue(
ReleaseRuntimePolicy.subscriptionButtonsEnabled(
canUseInAppPurchases: true,
isPurchasing: false,
isLoadingProducts: false
)
)
XCTAssertFalse(
ReleaseRuntimePolicy.subscriptionButtonsEnabled(
canUseInAppPurchases: false,
isPurchasing: false,
isLoadingProducts: false
)
)
XCTAssertFalse(
ReleaseRuntimePolicy.subscriptionButtonsEnabled(
canUseInAppPurchases: true,
isPurchasing: true,
isLoadingProducts: false
)
)
XCTAssertFalse(
ReleaseRuntimePolicy.subscriptionButtonsEnabled(
canUseInAppPurchases: true,
isPurchasing: false,
isLoadingProducts: true
)
)
}
}

49
scripts/ci/release_preflight.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
TAG="${1:-}"
if [[ -z "$TAG" ]]; then
echo "Usage: scripts/ci/release_preflight.sh <tag>" >&2
exit 1
fi
if [[ "$TAG" != v* ]]; then
TAG="v$TAG"
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
scripts/ci/select_xcode17.sh
echo "Validating release docs for $TAG..."
./scripts/extract_changelog_section.sh CHANGELOG.md "$TAG" > /tmp/release-notes-"$TAG".md
if grep -nE "^- TODO$" /tmp/release-notes-"$TAG".md >/dev/null; then
echo "CHANGELOG section for ${TAG} still contains TODO entries." >&2
exit 1
fi
grep -nE "^> Latest release: \\*\\*${TAG}\\*\\*$" README.md >/dev/null
grep -nE "^- Latest release: \\*\\*${TAG}\\*\\*$" README.md >/dev/null
grep -nE "^### ${TAG} \\(summary\\)$" README.md >/dev/null
SAFE_TAG="$(echo "$TAG" | tr -c 'A-Za-z0-9_' '_')"
WORK_DIR="/tmp/nve_release_preflight_${SAFE_TAG}"
DERIVED="${WORK_DIR}/DerivedData"
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
echo "Running critical runtime tests..."
xcodebuild \
-project "Neon Vision Editor.xcodeproj" \
-scheme "Neon Vision Editor" \
-destination "platform=macOS" \
-derivedDataPath "$DERIVED" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
-only-testing:"Neon Vision EditorTests/ReleaseRuntimePolicyTests" \
test >"${WORK_DIR}/test.log"
APP="$DERIVED/Build/Products/Debug/Neon Vision Editor.app"
scripts/ci/verify_icon_payload.sh "$APP"
echo "Preflight checks passed for $TAG."

30
scripts/ci/select_xcode17.sh Executable file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "${DEVELOPER_DIR:-}" ]]; then
xcodebuild -version
fi
if xcodebuild -version | awk '/Xcode/ {split($2, v, "."); if (v[1] >= 17) exit 0; exit 1}'; then
exit 0
fi
for candidate in \
/Applications/Xcode_17.*/Contents/Developer \
/Applications/Xcode-17.*/Contents/Developer \
/Applications/Xcode.app/Contents/Developer
do
for path in $candidate; do
if [[ -d "$path" ]]; then
export DEVELOPER_DIR="$path"
if xcodebuild -version | awk '/Xcode/ {split($2, v, "."); if (v[1] >= 17) exit 0; exit 1}'; then
echo "Using DEVELOPER_DIR=$DEVELOPER_DIR"
exit 0
fi
fi
done
done
echo "Xcode 17+ is required but not available on this runner." >&2
xcodebuild -version || true
exit 1

View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="${1:-}"
if [[ -z "$APP_PATH" || ! -d "$APP_PATH" ]]; then
echo "Usage: scripts/ci/verify_icon_payload.sh <path-to-.app>" >&2
exit 1
fi
INFO="$APP_PATH/Contents/Info.plist"
CAR="$APP_PATH/Contents/Resources/Assets.car"
if [[ ! -f "$INFO" ]]; then
echo "Missing Info.plist at $INFO" >&2
exit 1
fi
if [[ ! -f "$CAR" ]]; then
echo "Missing Assets.car at $CAR" >&2
exit 1
fi
ICON_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$INFO" 2>/dev/null || true)"
if [[ "$ICON_NAME" != "AppIcon" ]]; then
echo "Unexpected CFBundleIconName: '$ICON_NAME' (expected 'AppIcon')." >&2
exit 1
fi
TMP_JSON="$(mktemp)"
xcrun --sdk macosx assetutil --info "$CAR" > "$TMP_JSON"
if ! grep -Eq '"RenditionName" : "AppIcon\.iconstack"' "$TMP_JSON"; then
echo "Missing AppIcon iconstack rendition in Assets.car." >&2
rm -f "$TMP_JSON"
exit 1
fi
if ! grep -Eq '"Name" : "AppIcon"' "$TMP_JSON"; then
echo "Missing AppIcon image renditions in Assets.car." >&2
rm -f "$TMP_JSON"
exit 1
fi
rm -f "$TMP_JSON"
echo "Icon payload preflight passed."

View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
TAG="${1:-}"
if [[ -z "$TAG" ]]; then
echo "Usage: scripts/ci/verify_release_asset.sh <tag>" >&2
exit 1
fi
if [[ "$TAG" != v* ]]; then
TAG="v$TAG"
fi
if ! command -v gh >/dev/null 2>&1; then
echo "gh CLI is required." >&2
exit 1
fi
WORK_DIR="/tmp/nve_release_asset_verify_${TAG}"
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
gh release download "$TAG" -p Neon.Vision.Editor.app.zip -D "$WORK_DIR"
ditto -x -k "$WORK_DIR/Neon.Vision.Editor.app.zip" "$WORK_DIR/extracted"
APP="$WORK_DIR/extracted/Neon Vision Editor.app"
scripts/ci/verify_icon_payload.sh "$APP"
echo "Release asset verification passed for $TAG."

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
REPO="${1:-h3pdesign/Neon-Vision-Editor}"
BRANCH="${2:-main}"
if ! command -v gh >/dev/null 2>&1; then
echo "gh CLI is required." >&2
exit 1
fi
echo "Applying branch protection on ${REPO}:${BRANCH}..."
gh api --method PUT "repos/${REPO}/branches/${BRANCH}/protection" \
--input - <<'JSON'
{
"required_status_checks": {
"strict": true,
"contexts": [
"Pre-release CI / preflight"
]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"dismiss_stale_reviews": true,
"require_code_owner_reviews": false,
"required_approving_review_count": 1
},
"restrictions": null,
"required_linear_history": true,
"allow_force_pushes": false,
"allow_deletions": false,
"block_creations": false,
"required_conversation_resolution": true
}
JSON
echo "Branch protection updated successfully."

View file

@ -6,20 +6,22 @@ usage() {
Run end-to-end release flow in one command.
Usage:
scripts/release_all.sh <tag> [--date YYYY-MM-DD] [--notarized] [--self-hosted]
scripts/release_all.sh <tag> [--date YYYY-MM-DD] [--skip-notarized] [--github-hosted] [--dry-run]
Examples:
scripts/release_all.sh v0.4.6
scripts/release_all.sh 0.4.6 --date 2026-02-12
scripts/release_all.sh v0.4.6 --notarized
scripts/release_all.sh v0.4.6 --notarized --self-hosted
scripts/release_all.sh v0.4.9
scripts/release_all.sh 0.4.9 --date 2026-02-12
scripts/release_all.sh v0.4.9 --github-hosted
scripts/release_all.sh v0.4.9 --dry-run
What it does:
1) Prepare README/CHANGELOG docs
2) Commit docs changes
3) Create annotated tag
4) Push main and tag to origin
5) (optional) Trigger notarized release workflow (GitHub-hosted by default)
1) Run release preflight checks (docs + build + icon payload + tests)
2) Prepare README/CHANGELOG docs
3) Commit docs changes
4) Create annotated tag
5) Push main and tag to origin
6) Trigger notarized release workflow (self-hosted by default)
7) Wait for notarized workflow and verify uploaded release asset payload
EOF
}
@ -37,8 +39,9 @@ if [[ "$TAG" != v* ]]; then
fi
DATE_ARG=()
TRIGGER_NOTARIZED=0
USE_SELF_HOSTED=0
TRIGGER_NOTARIZED=1
USE_SELF_HOSTED=1
DRY_RUN=0
while [[ "${1:-}" != "" ]]; do
case "$1" in
@ -50,11 +53,14 @@ while [[ "${1:-}" != "" ]]; do
fi
DATE_ARG=(--date "$1")
;;
--notarized)
TRIGGER_NOTARIZED=1
--skip-notarized)
TRIGGER_NOTARIZED=0
;;
--self-hosted)
USE_SELF_HOSTED=1
--github-hosted)
USE_SELF_HOSTED=0
;;
--dry-run)
DRY_RUN=1
;;
*)
echo "Unknown argument: $1" >&2
@ -70,6 +76,14 @@ if ! command -v gh >/dev/null 2>&1; then
exit 1
fi
echo "Running release preflight for ${TAG}..."
scripts/ci/release_preflight.sh "$TAG"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Dry-run requested. Preflight completed; no commits/tags/workflows were created."
exit 0
fi
echo "Running release prep for ${TAG}..."
prep_cmd=(scripts/release_prep.sh "$TAG")
if [[ ${#DATE_ARG[@]} -gt 0 ]]; then
@ -78,22 +92,35 @@ fi
prep_cmd+=(--push)
"${prep_cmd[@]}"
echo "Tag push completed. Unsigned release workflow should start automatically."
echo "Tag push completed."
if [[ "$TRIGGER_NOTARIZED" -eq 1 ]]; then
echo "Triggering notarized workflow for ${TAG}..."
if [[ "$USE_SELF_HOSTED" -eq 1 ]]; then
gh workflow run release-notarized-selfhosted.yml -f tag="$TAG" -f use_self_hosted=true
echo "Triggered: release-notarized-selfhosted.yml (tag=${TAG}, use_self_hosted=true)"
WORKFLOW_NAME="release-notarized-selfhosted.yml"
echo "Triggered: ${WORKFLOW_NAME} (tag=${TAG}, use_self_hosted=true)"
else
gh workflow run release-notarized.yml -f tag="$TAG"
echo "Triggered: release-notarized.yml (tag=${TAG})"
WORKFLOW_NAME="release-notarized.yml"
echo "Triggered: ${WORKFLOW_NAME} (tag=${TAG})"
fi
echo "Waiting for ${WORKFLOW_NAME} run..."
sleep 6
RUN_ID="$(gh run list --workflow "$WORKFLOW_NAME" --limit 20 --json databaseId,displayTitle --jq ".[] | select(.displayTitle | contains(\"${TAG}\")) | .databaseId" | head -n1)"
if [[ -z "$RUN_ID" ]]; then
echo "Could not find workflow run for ${TAG}." >&2
exit 1
fi
gh run watch "$RUN_ID"
scripts/ci/verify_release_asset.sh "$TAG"
fi
echo
echo "Done."
echo "Check runs:"
echo " gh run list --workflow release.yml --limit 5"
echo " gh run list --workflow pre-release-ci.yml --limit 5"
echo " gh run list --workflow release-dry-run.yml --limit 5"
echo " gh run list --workflow release-notarized.yml --limit 5"
echo " gh run list --workflow release-notarized-selfhosted.yml --limit 5"

41
scripts/release_dry_run.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Validate release readiness without creating a tag or pushing.
Usage:
scripts/release_dry_run.sh <tag>
Example:
scripts/release_dry_run.sh v0.4.9
EOF
}
if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
TAG="$1"
if [[ "$TAG" != v* ]]; then
TAG="v$TAG"
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_WORKTREE="/tmp/nve_release_dry_run_${TAG}_$$"
git -C "$ROOT" worktree add "$TMP_WORKTREE" HEAD >/dev/null
cleanup() {
git -C "$ROOT" worktree remove "$TMP_WORKTREE" --force >/dev/null 2>&1 || true
}
trap cleanup EXIT
(
cd "$TMP_WORKTREE"
scripts/ci/release_preflight.sh "$TAG"
scripts/release_prep.sh "$TAG"
)
echo "Dry-run finished. Release content for ${TAG} validated in temporary worktree."