From b2420e73b31af24da1c8202037a75ab094ea1f31 Mon Sep 17 00:00:00 2001 From: h3p Date: Fri, 13 Feb 2026 00:58:32 +0100 Subject: [PATCH] ci(release): harden notarized pipeline and automate preflight --- .github/workflows/pre-release-ci.yml | 65 +++++++++++++ .github/workflows/release-dry-run.yml | 35 +++++++ .../release-notarized-selfhosted.yml | 62 +++++++----- .github/workflows/release-notarized.yml | 64 +++++++------ .github/workflows/release.yml | 68 ++++--------- Neon Vision Editor.xcodeproj/project.pbxproj | 4 +- .../xcschemes/Neon Vision Editor.xcscheme | 13 +++ .../App/NeonVisionEditorApp.swift | 9 +- .../Core/ReleaseRuntimePolicy.swift | 61 ++++++++++++ .../UI/ContentView+Actions.swift | 33 +++---- Neon Vision Editor/UI/NeonSettingsView.swift | 9 +- .../ReleaseRuntimePolicyTests.swift | 95 +++++++++++++++++++ scripts/ci/release_preflight.sh | 49 ++++++++++ scripts/ci/select_xcode17.sh | 30 ++++++ scripts/ci/verify_icon_payload.sh | 45 +++++++++ scripts/ci/verify_release_asset.sh | 27 ++++++ scripts/configure_branch_protection.sh | 37 ++++++++ scripts/release_all.sh | 67 +++++++++---- scripts/release_dry_run.sh | 41 ++++++++ 19 files changed, 655 insertions(+), 159 deletions(-) create mode 100644 .github/workflows/pre-release-ci.yml create mode 100644 .github/workflows/release-dry-run.yml create mode 100644 Neon Vision Editor/Core/ReleaseRuntimePolicy.swift create mode 100644 Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift create mode 100755 scripts/ci/release_preflight.sh create mode 100755 scripts/ci/select_xcode17.sh create mode 100755 scripts/ci/verify_icon_payload.sh create mode 100755 scripts/ci/verify_release_asset.sh create mode 100755 scripts/configure_branch_protection.sh create mode 100755 scripts/release_dry_run.sh 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."