diff --git a/.github/workflows/pre-release-ci.yml b/.github/workflows/pre-release-ci.yml
new file mode 100644
index 0000000..772c9bb
--- /dev/null
+++ b/.github/workflows/pre-release-ci.yml
@@ -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"
diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml
new file mode 100644
index 0000000..691bb83
--- /dev/null
+++ b/.github/workflows/release-dry-run.yml
@@ -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"
diff --git a/.github/workflows/release-notarized-selfhosted.yml b/.github/workflows/release-notarized-selfhosted.yml
index 66fdde0..228e83b 100644
--- a/.github/workflows/release-notarized-selfhosted.yml
+++ b/.github/workflows/release-notarized-selfhosted.yml
@@ -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 }}
diff --git a/.github/workflows/release-notarized.yml b/.github/workflows/release-notarized.yml
index 8edecca..688fd6a 100644
--- a/.github/workflows/release-notarized.yml
+++ b/.github/workflows/release-notarized.yml
@@ -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 }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 42e3d32..d3ea092 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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."
diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj
index 954ae5c..e23cf3b 100644
--- a/Neon Vision Editor.xcodeproj/project.pbxproj
+++ b/Neon Vision Editor.xcodeproj/project.pbxproj
@@ -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;
diff --git a/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme b/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme
index bc92222..fc2e706 100644
--- a/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme
+++ b/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme
@@ -29,6 +29,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
+
+
+
+
+
+
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
+ }
+}
diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift
index 09e73bc..b2796d3 100644
--- a/Neon Vision Editor/UI/ContentView+Actions.swift
+++ b/Neon Vision Editor/UI/ContentView+Actions.swift
@@ -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
diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift
index 66f5d0f..1090305 100644
--- a/Neon Vision Editor/UI/NeonSettingsView.swift
+++ b/Neon Vision Editor/UI/NeonSettingsView.swift
@@ -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)
diff --git a/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift b/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift
new file mode 100644
index 0000000..0f74add
--- /dev/null
+++ b/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift
@@ -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
+ )
+ )
+ }
+}
diff --git a/scripts/ci/release_preflight.sh b/scripts/ci/release_preflight.sh
new file mode 100755
index 0000000..7989413
--- /dev/null
+++ b/scripts/ci/release_preflight.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+TAG="${1:-}"
+if [[ -z "$TAG" ]]; then
+ echo "Usage: scripts/ci/release_preflight.sh " >&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."
diff --git a/scripts/ci/select_xcode17.sh b/scripts/ci/select_xcode17.sh
new file mode 100755
index 0000000..bf5726d
--- /dev/null
+++ b/scripts/ci/select_xcode17.sh
@@ -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
diff --git a/scripts/ci/verify_icon_payload.sh b/scripts/ci/verify_icon_payload.sh
new file mode 100755
index 0000000..073408a
--- /dev/null
+++ b/scripts/ci/verify_icon_payload.sh
@@ -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 " >&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."
diff --git a/scripts/ci/verify_release_asset.sh b/scripts/ci/verify_release_asset.sh
new file mode 100755
index 0000000..385fcca
--- /dev/null
+++ b/scripts/ci/verify_release_asset.sh
@@ -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 " >&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."
diff --git a/scripts/configure_branch_protection.sh b/scripts/configure_branch_protection.sh
new file mode 100755
index 0000000..517ad22
--- /dev/null
+++ b/scripts/configure_branch_protection.sh
@@ -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."
diff --git a/scripts/release_all.sh b/scripts/release_all.sh
index d844cd2..c8a9a8e 100755
--- a/scripts/release_all.sh
+++ b/scripts/release_all.sh
@@ -6,20 +6,22 @@ usage() {
Run end-to-end release flow in one command.
Usage:
- scripts/release_all.sh [--date YYYY-MM-DD] [--notarized] [--self-hosted]
+ scripts/release_all.sh [--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"
diff --git a/scripts/release_dry_run.sh b/scripts/release_dry_run.sh
new file mode 100755
index 0000000..2af90ad
--- /dev/null
+++ b/scripts/release_dry_run.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <
+
+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."