diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6659d4..4cacdda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,5 +50,20 @@ jobs: test "$clipboard_after" = "$CLIPBOARD_BEFORE" test "$keychain_items_after" = "$KEYCHAIN_ITEMS_BEFORE" - - name: Build Release App Bundle + - name: Build Release App Bundle (universal) + env: + UNIVERSAL: "1" + SCRATCH_PATH: ${{ runner.temp }}/swift-build-universal run: ./Scripts/build_app.sh + + - name: Verify App Bundle + shell: bash + run: | + app=MacOptimizer.app + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = "$(tr -d '[:space:]' < VERSION)" + test -f "$app/Contents/Resources/AppIcon.icns" + test -d "$app/Contents/Resources/MacOptimizer_MacOptimizer.bundle/Contents/Resources/en.lproj" + test -d "$app/Contents/Resources/MacOptimizer_MacOptimizer.bundle/Contents/Resources/tr.lproj" + lipo "$app/Contents/MacOS/MacOptimizer" -verify_arch arm64 + lipo "$app/Contents/MacOS/MacOptimizer" -verify_arch x86_64 + "$app/Contents/MacOS/MacOptimizer" version | grep -F "v$(tr -d '[:space:]' < VERSION)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e21c056..a228b91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,55 +1,163 @@ name: Release Build +# Tags v* build an UNSIGNED universal DMG as a workflow artifact (never a release asset). +# If the signing secrets exist, a second job signs, notarizes and staples the DMG with +# Scripts/release.sh and creates a DRAFT GitHub release for the human to publish. +# Without secrets the signed release is produced locally (docs/RELEASING.md). + on: push: tags: - 'v*' permissions: - contents: write - id-token: write - attestations: write + contents: read jobs: - release: - name: Build & Release + build-unsigned: + name: Build unsigned DMG runs-on: macos-15 - steps: - name: Checkout Repository uses: actions/checkout@v4 + with: + fetch-depth: 0 # CFBundleVersion = git rev-list --count HEAD - name: Select Xcode 16.4 run: | - ls /Applications | grep Xcode test -d /Applications/Xcode_16.4.app || { echo "Xcode 16.4 is missing from this runner" >&2; exit 1; } sudo xcode-select --switch /Applications/Xcode_16.4.app - - name: Verify Swift Version - run: swift --version + - name: Check tag matches VERSION + shell: bash + run: | + version="$(tr -d '[:space:]' < VERSION)" + test "${GITHUB_REF_NAME}" = "v${version}" || { echo "tag ${GITHUB_REF_NAME} != v${version} (VERSION file)" >&2; exit 1; } + echo "VERSION=${version}" >> "$GITHUB_ENV" - - name: Build Standalone Application + - name: Build app bundle (universal) + env: + UNIVERSAL: "1" + SCRATCH_PATH: ${{ runner.temp }}/swift-build run: ./Scripts/build_app.sh - - name: Package DMG Installer + - name: Package unsigned DMG + shell: bash + run: | + mkdir -p dist/dmg-root + ditto MacOptimizer.app dist/dmg-root/MacOptimizer.app + ln -s /Applications dist/dmg-root/Applications + dmg="MacOptimizer-${VERSION}-unsigned.dmg" + hdiutil create -volname "MacOptimizer ${VERSION}" -srcfolder dist/dmg-root -fs HFS+ -format UDZO -ov "dist/${dmg}" + (cd dist && shasum -a 256 "${dmg}" > "${dmg}.sha256") + + - name: Upload unsigned artifact + uses: actions/upload-artifact@v4 + with: + name: MacOptimizer-unsigned + path: | + dist/*.dmg + dist/*.sha256 + if-no-files-found: error + + check-signing-secrets: + name: Check signing secrets + runs-on: ubuntu-latest + outputs: + enabled: ${{ steps.check.outputs.enabled }} + steps: + - id: check + env: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + if [ -n "$MACOS_CERT_P12" ] && [ -n "$MACOS_CERT_PASSWORD" ] && [ -n "$ASC_KEY_ID" ] \ + && [ -n "$ASC_ISSUER_ID" ] && [ -n "$ASC_KEY_P8" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice::Signing secrets not configured; skipping signed release (sign locally, see docs/RELEASING.md)." + fi + + sign-and-notarize: + name: Sign, notarize and draft release + needs: [build-unsigned, check-signing-secrets] + if: needs.check-signing-secrets.outputs.enabled == 'true' + runs-on: macos-15 + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Select Xcode 16.4 + run: sudo xcode-select --switch /Applications/Xcode_16.4.app + + - name: Import signing certificate into a temporary keychain + env: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + shell: bash + run: | + set -euo pipefail + keychain="${RUNNER_TEMP}/signing.keychain-db" + keychain_password="$(uuidgen)" + cert="${RUNNER_TEMP}/cert.p12" + printf '%s' "${MACOS_CERT_P12}" | base64 --decode > "${cert}" + security create-keychain -p "${keychain_password}" "${keychain}" + security set-keychain-settings -lut 3600 "${keychain}" + security unlock-keychain -p "${keychain_password}" "${keychain}" + security import "${cert}" -k "${keychain}" -P "${MACOS_CERT_PASSWORD}" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain}" >/dev/null + # shellcheck disable=SC2046 # the existing keychain list must word-split + security list-keychains -d user -s "${keychain}" $(security list-keychains -d user | tr -d '"') + rm -f "${cert}" + identity="$(security find-identity -v -p codesigning "${keychain}" | sed -n 's/.*"\(Developer ID Application: .*\)"/\1/p' | head -n 1)" + test -n "${identity}" || { echo "No Developer ID Application identity in the certificate" >&2; exit 1; } + echo "SIGN_IDENTITY=${identity}" >> "$GITHUB_ENV" + echo "SIGNING_KEYCHAIN=${keychain}" >> "$GITHUB_ENV" + + - name: Write notary API key + env: + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + shell: bash run: | - mkdir -p dist - hdiutil create -volname "MacOptimizer" -srcfolder "MacOptimizer.app" -ov -format UDZO "dist/MacOptimizer.dmg" - cd dist - shasum -a 256 MacOptimizer.dmg > MacOptimizer.dmg.sha256 - cd .. + umask 077 + printf '%s' "${ASC_KEY_P8}" > "${RUNNER_TEMP}/AuthKey.p8" + echo "NOTARY_KEY_PATH=${RUNNER_TEMP}/AuthKey.p8" >> "$GITHUB_ENV" - - name: Generate Artifact Attestation + - name: Build, sign, notarize and staple + env: + NOTARY_KEY_ID: ${{ secrets.ASC_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + SCRATCH_PATH: ${{ runner.temp }}/swift-build + run: ./Scripts/release.sh + + - name: Generate artifact attestation uses: actions/attest-build-provenance@v1 with: - subject-path: 'dist/MacOptimizer.dmg' + subject-path: 'dist/MacOptimizer-*.dmg' - - name: Create GitHub Release + - name: Create draft GitHub release uses: softprops/action-gh-release@v2 with: files: | - dist/MacOptimizer.dmg - dist/MacOptimizer.dmg.sha256 + dist/MacOptimizer-*.dmg + dist/MacOptimizer-*.dmg.sha256 generate_release_notes: true - draft: false + draft: true prerelease: false + + - name: Clean up signing material + if: always() + shell: bash + run: | + rm -f "${RUNNER_TEMP}/AuthKey.p8" "${RUNNER_TEMP}/cert.p12" + if [ -n "${SIGNING_KEYCHAIN:-}" ]; then security delete-keychain "${SIGNING_KEYCHAIN}" || true; fi diff --git a/Resources/Brand/AppIcon-1024.png b/Resources/Brand/AppIcon-1024.png new file mode 100644 index 0000000..634acbd Binary files /dev/null and b/Resources/Brand/AppIcon-1024.png differ diff --git a/Scripts/MacOptimizer.entitlements b/Scripts/MacOptimizer.entitlements new file mode 100644 index 0000000..08204f6 --- /dev/null +++ b/Scripts/MacOptimizer.entitlements @@ -0,0 +1,21 @@ + + + + + + diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh index 36e0121..8b57519 100755 --- a/Scripts/build_app.sh +++ b/Scripts/build_app.sh @@ -1,30 +1,67 @@ #!/bin/bash -set -e +# Builds MacOptimizer.app from the SwiftPM executable. +# +# Environment: +# SCRATCH_PATH SwiftPM scratch path (default: .build) +# OUTPUT_DIR where MacOptimizer.app is written (default: repo root) +# UNIVERSAL=1 build a universal (arm64 + x86_64) binary +# BUILD_NUMBER CFBundleVersion override (default: git rev-list --count HEAD) +set -euo pipefail -echo "🚀 Building MacOptimizer in Release mode..." -SCRATCH_PATH="${SCRATCH_PATH:-.build}" -swift build -c release --scratch-path "${SCRATCH_PATH}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${REPO_ROOT}" APP_NAME="MacOptimizer" -BUILD_DIR="${SCRATCH_PATH}/release" -APP_BUNDLE="${APP_NAME}.app" +BUNDLE_ID="com.osmancagrigenc.MacOptimizer" +SCRATCH_PATH="${SCRATCH_PATH:-.build}" +OUTPUT_DIR="${OUTPUT_DIR:-.}" + +# Single version source: the VERSION file at the repo root. +VERSION="$(tr -d '[:space:]' < "${REPO_ROOT}/VERSION")" +if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: VERSION must be MAJOR.MINOR.PATCH, got '${VERSION}'" >&2 + exit 1 +fi +BUILD_NUMBER="${BUILD_NUMBER:-$(git -C "${REPO_ROOT}" rev-list --count HEAD 2>/dev/null || echo 1)}" + +ARCH_FLAGS=() +if [[ "${UNIVERSAL:-0}" == "1" ]]; then + ARCH_FLAGS=(--arch arm64 --arch x86_64) +fi + +echo "Building ${APP_NAME} ${VERSION} (${BUILD_NUMBER}) in release mode, universal=${UNIVERSAL:-0}..." +swift build -c release --scratch-path "${SCRATCH_PATH}" ${ARCH_FLAGS[@]+"${ARCH_FLAGS[@]}"} +BUILD_DIR="$(swift build -c release --scratch-path "${SCRATCH_PATH}" ${ARCH_FLAGS[@]+"${ARCH_FLAGS[@]}"} --show-bin-path)" + +mkdir -p "${OUTPUT_DIR}" +APP_BUNDLE="${OUTPUT_DIR}/${APP_NAME}.app" CONTENTS_DIR="${APP_BUNDLE}/Contents" MACOS_DIR="${CONTENTS_DIR}/MacOS" RESOURCES_DIR="${CONTENTS_DIR}/Resources" +RESOURCE_BUNDLE="${APP_NAME}_${APP_NAME}.bundle" -echo "📦 Creating .app bundle structure: ${APP_BUNDLE}..." +echo "Creating bundle: ${APP_BUNDLE}" rm -rf "${APP_BUNDLE}" -mkdir -p "${MACOS_DIR}" -mkdir -p "${RESOURCES_DIR}" +mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" -# Copy binary cp "${BUILD_DIR}/${APP_NAME}" "${MACOS_DIR}/${APP_NAME}" chmod +x "${MACOS_DIR}/${APP_NAME}" -# Bundle.module points to this SwiftPM resource bundle at runtime. -cp -R "${BUILD_DIR}/MacOptimizer_MacOptimizer.bundle" "${RESOURCES_DIR}/" +# Bundle.module resolves this SwiftPM resource bundle at runtime (localizations live here). +cp -R "${BUILD_DIR}/${RESOURCE_BUNDLE}" "${RESOURCES_DIR}/" +for lang in en tr; do + if [[ ! -d "${RESOURCES_DIR}/${RESOURCE_BUNDLE}/Contents/Resources/${lang}.lproj" \ + && ! -d "${RESOURCES_DIR}/${RESOURCE_BUNDLE}/${lang}.lproj" ]]; then + echo "error: ${lang}.lproj missing from ${RESOURCE_BUNDLE}" >&2 + exit 1 + fi +done + +# App icon, built from the master PNG Resources/Brand/AppIcon-1024.png. +"${REPO_ROOT}/Scripts/build_icns.sh" "${RESOURCES_DIR}/AppIcon.icns" -# Create Info.plist +# LSUIElement is false on purpose: MacOptimizer is a regular windowed app with a +# Dock icon; the menu bar extra is optional. cat < "${CONTENTS_DIR}/Info.plist" @@ -35,7 +72,7 @@ cat < "${CONTENTS_DIR}/Info.plist" CFBundleIconFile AppIcon CFBundleIdentifier - com.osmancagrigenc.MacOptimizer + ${BUNDLE_ID} CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -45,9 +82,9 @@ cat < "${CONTENTS_DIR}/Info.plist" CFBundlePackageType APPL CFBundleShortVersionString - 1.0.0 + ${VERSION} CFBundleVersion - 1 + ${BUILD_NUMBER} CFBundleDevelopmentRegion en CFBundleLocalizations @@ -55,15 +92,19 @@ cat < "${CONTENTS_DIR}/Info.plist" en tr + LSApplicationCategoryType + public.app-category.utilities LSMinimumSystemVersion 14.0 + LSUIElement + NSHighResolutionCapable NSHumanReadableCopyright - Copyright © 2026 MacOptimizer. All rights reserved. + Copyright © 2026 Osman Cagri Genc (Codifya). Licensed under Apache-2.0. EOF +plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null -echo "✨ ${APP_BUNDLE} created successfully!" -echo "📍 Path: $(pwd)/${APP_BUNDLE}" +echo "${APP_BUNDLE} created (version ${VERSION}, build ${BUILD_NUMBER})." diff --git a/Scripts/build_icns.sh b/Scripts/build_icns.sh new file mode 100755 index 0000000..2b8a702 --- /dev/null +++ b/Scripts/build_icns.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Builds AppIcon.icns from the 1024x1024 master PNG. +# +# Usage: Scripts/build_icns.sh [master.png] +# master defaults to Resources/Brand/AppIcon-1024.png. To change the app icon, replace +# that file. If it is missing, the placeholder generator (Scripts/make_icon.swift) +# writes it first. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT="${1:?usage: build_icns.sh [master.png]}" +MASTER="${2:-${REPO_ROOT}/Resources/Brand/AppIcon-1024.png}" + +if [[ ! -f "${MASTER}" ]]; then + echo "Master icon missing; generating placeholder at ${MASTER}" + swift "${REPO_ROOT}/Scripts/make_icon.swift" "${MASTER}" +fi + +width="$(sips -g pixelWidth "${MASTER}" | awk '/pixelWidth/ {print $2}')" +height="$(sips -g pixelHeight "${MASTER}" | awk '/pixelHeight/ {print $2}')" +if [[ "${width}" != "1024" || "${height}" != "1024" ]]; then + echo "error: ${MASTER} must be 1024x1024 PNG, got ${width}x${height}" >&2 + exit 1 +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "${WORK_DIR}"' EXIT +ICONSET="${WORK_DIR}/AppIcon.iconset" +mkdir -p "${ICONSET}" + +# name:pixels for every iconset rendition macOS expects. +for spec in \ + icon_16x16.png:16 icon_16x16@2x.png:32 \ + icon_32x32.png:32 icon_32x32@2x.png:64 \ + icon_128x128.png:128 icon_128x128@2x.png:256 \ + icon_256x256.png:256 icon_256x256@2x.png:512 \ + icon_512x512.png:512 icon_512x512@2x.png:1024; do + name="${spec%%:*}" + px="${spec##*:}" + sips -s format png -z "${px}" "${px}" "${MASTER}" --out "${ICONSET}/${name}" >/dev/null +done + +mkdir -p "$(dirname "${OUTPUT}")" +iconutil -c icns "${ICONSET}" -o "${OUTPUT}" +echo "Wrote ${OUTPUT}" diff --git a/Scripts/make_icon.swift b/Scripts/make_icon.swift new file mode 100644 index 0000000..9870e93 --- /dev/null +++ b/Scripts/make_icon.swift @@ -0,0 +1,124 @@ +#!/usr/bin/env swift +// Fallback icon generator. Writes the 1024x1024 master PNG that Scripts/build_icns.sh +// turns into AppIcon.icns: an original, programmatically drawn placeholder +// (rounded-square gradient, white gauge arc with a needle and a small sparkle). +// No third-party artwork. Uses only CoreGraphics + ImageIO, so it runs headless. +// The designed logo replaces the master PNG; this script only runs when it is missing. +// +// Usage: swift Scripts/make_icon.swift [output.png] (default: Resources/Brand/AppIcon-1024.png) + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +let outputPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Resources/Brand/AppIcon-1024.png" + +func color(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> CGColor { + CGColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: a) +} + +/// Draws the icon on a 1024-unit canvas; the context is pre-scaled to the target size. +func drawIcon(in ctx: CGContext) { + // macOS icon grid: 824pt body centred on a 1024pt canvas, ~185pt corner radius. + let body = CGRect(x: 100, y: 100, width: 824, height: 824) + let bodyPath = CGPath(roundedRect: body, cornerWidth: 185, cornerHeight: 185, transform: nil) + + // Soft drop shadow under the body. + ctx.saveGState() + ctx.setShadow(offset: CGSize(width: 0, height: -12), blur: 28, color: color(0, 0, 0, 0.35)) + ctx.addPath(bodyPath) + ctx.setFillColor(color(20, 40, 80)) + ctx.fillPath() + ctx.restoreGState() + + // Diagonal gradient fill: teal (bottom-left) to deep blue (top-right). + ctx.saveGState() + ctx.addPath(bodyPath) + ctx.clip() + let gradient = CGGradient( + colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), + colors: [color(24, 196, 172), color(38, 110, 230), color(58, 52, 170)] as CFArray, + locations: [0, 0.55, 1] + )! + ctx.drawLinearGradient(gradient, start: CGPoint(x: 100, y: 100), end: CGPoint(x: 924, y: 924), options: []) + ctx.restoreGState() + + // Gauge: a 240-degree arc opening downwards. + let center = CGPoint(x: 512, y: 470) + let radius: CGFloat = 250 + let startAngle = CGFloat.pi * (-30.0 / 180.0) // lower right + let endAngle = CGFloat.pi * (210.0 / 180.0) // lower left + ctx.setLineCap(.round) + + // Track. + ctx.setStrokeColor(color(255, 255, 255, 0.28)) + ctx.setLineWidth(58) + ctx.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false) + ctx.strokePath() + + // Filled portion (from the left end, clockwise to ~75%). + let valueAngle = CGFloat.pi * (20.0 / 180.0) + ctx.setStrokeColor(color(255, 255, 255)) + ctx.addArc(center: center, radius: radius, startAngle: endAngle, endAngle: valueAngle, clockwise: true) + ctx.strokePath() + + // Needle pointing at the value. + let needleLength: CGFloat = 195 + let tip = CGPoint(x: center.x + cos(valueAngle) * needleLength, y: center.y + sin(valueAngle) * needleLength) + ctx.setLineWidth(34) + ctx.move(to: center) + ctx.addLine(to: tip) + ctx.strokePath() + ctx.setFillColor(color(255, 255, 255)) + ctx.fillEllipse(in: CGRect(x: center.x - 46, y: center.y - 46, width: 92, height: 92)) + + // Four-point sparkle in the upper-left corner. + drawSparkle(in: ctx, at: CGPoint(x: 300, y: 760), size: 88) +} + +func drawSparkle(in ctx: CGContext, at c: CGPoint, size s: CGFloat) { + let waist = s * 0.22 + let path = CGMutablePath() + path.move(to: CGPoint(x: c.x, y: c.y + s)) + path.addQuadCurve(to: CGPoint(x: c.x + s, y: c.y), control: CGPoint(x: c.x + waist, y: c.y + waist)) + path.addQuadCurve(to: CGPoint(x: c.x, y: c.y - s), control: CGPoint(x: c.x + waist, y: c.y - waist)) + path.addQuadCurve(to: CGPoint(x: c.x - s, y: c.y), control: CGPoint(x: c.x - waist, y: c.y - waist)) + path.addQuadCurve(to: CGPoint(x: c.x, y: c.y + s), control: CGPoint(x: c.x - waist, y: c.y + waist)) + path.closeSubpath() + ctx.addPath(path) + ctx.setFillColor(color(255, 255, 255, 0.95)) + ctx.fillPath() +} + +func renderPNG(size: Int, to url: URL) throws { + guard let ctx = CGContext( + data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { throw IconError.context(size) } + ctx.setShouldAntialias(true) + ctx.interpolationQuality = .high + ctx.scaleBy(x: CGFloat(size) / 1024, y: CGFloat(size) / 1024) + drawIcon(in: ctx) + guard let image = ctx.makeImage(), + let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) + else { throw IconError.encode(url.path) } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { throw IconError.encode(url.path) } +} + +enum IconError: Error { + case context(Int) + case encode(String) +} + +do { + let output = URL(fileURLWithPath: outputPath) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try renderPNG(size: 1024, to: output) + print("Wrote \(output.path)") +} catch { + FileHandle.standardError.write("make_icon failed: \(error)\n".data(using: .utf8)!) + exit(1) +} diff --git a/Scripts/release.sh b/Scripts/release.sh new file mode 100755 index 0000000..52bbf7f --- /dev/null +++ b/Scripts/release.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# Local release: universal build -> Developer ID signing (hardened runtime) -> DMG -> +# notarization -> stapling -> Gatekeeper checks -> checksum. +# +# Flow: the app is signed, placed in a signed DMG, and the DMG is submitted to the notary +# service once. Notarizing the DMG also notarizes the app inside it; the ticket is stapled +# to the DMG. See docs/RELEASING.md. +# +# Environment: +# SIGN_IDENTITY codesign identity (default: Developer ID Application: OSMAN CAGRI GENC (N83DXM47FV)) +# NOTARY_PROFILE notarytool keychain profile (default: codifya-notary) +# NOTARY_KEY_PATH, NOTARY_KEY_ID, NOTARY_ISSUER_ID +# notarize with an App Store Connect API key instead of the profile (CI) +# DRY_RUN=1 sign and package, but skip notarization and stapling +# UNIVERSAL=0 build for the host architecture only (default: universal arm64 + x86_64) +# SCRATCH_PATH SwiftPM scratch path (default: .build) +# ALLOW_DIRTY=1 allow a real (non-dry) release from a dirty working tree +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${REPO_ROOT}" + +APP_NAME="MacOptimizer" +SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application: OSMAN CAGRI GENC (N83DXM47FV)}" +NOTARY_PROFILE="${NOTARY_PROFILE:-codifya-notary}" +DRY_RUN="${DRY_RUN:-0}" +ENTITLEMENTS="${REPO_ROOT}/Scripts/MacOptimizer.entitlements" +VERSION="$(tr -d '[:space:]' < "${REPO_ROOT}/VERSION")" +DIST_DIR="${REPO_ROOT}/dist" +STAGE_DIR="${DIST_DIR}/stage" +APP_BUNDLE="${STAGE_DIR}/${APP_NAME}.app" +DMG_PATH="${DIST_DIR}/${APP_NAME}-${VERSION}.dmg" + +step() { printf '\n==> %s\n' "$*"; } +fail() { echo "error: $*" >&2; exit 1; } + +preflight() { + step "Preflight (version ${VERSION}, dry run: ${DRY_RUN})" + if [[ -n "$(git status --porcelain)" ]]; then + if [[ "${DRY_RUN}" == "1" || "${ALLOW_DIRTY:-0}" == "1" ]]; then + echo "warning: working tree is dirty" + else + fail "working tree is dirty; commit first or set ALLOW_DIRTY=1" + fi + fi + security find-identity -v -p codesigning | grep -qF "\"${SIGN_IDENTITY}\"" \ + || fail "signing identity not found in keychain: ${SIGN_IDENTITY}" + [[ -f "${ENTITLEMENTS}" ]] || fail "missing ${ENTITLEMENTS}" + plutil -lint "${ENTITLEMENTS}" >/dev/null +} + +build_app() { + step "Building app bundle" + rm -rf "${STAGE_DIR}" + mkdir -p "${STAGE_DIR}" + UNIVERSAL="${UNIVERSAL:-1}" OUTPUT_DIR="${STAGE_DIR}" "${REPO_ROOT}/Scripts/build_app.sh" + lipo -info "${APP_BUNDLE}/Contents/MacOS/${APP_NAME}" +} + +# The bundle must contain exactly one Mach-O (the main executable); the SwiftPM resource +# bundle is data only. If this ever changes, nested code must be signed explicitly first. +assert_single_executable() { + local machos=0 f + while IFS= read -r -d '' f; do + if file -b "${f}" | head -n 1 | grep -q 'Mach-O'; then + machos=$((machos + 1)) + fi + done < <(find "${APP_BUNDLE}" -type f -print0) + [[ "${machos}" == "1" ]] || fail "expected 1 Mach-O in the bundle, found ${machos}; sign nested code explicitly" +} + +sign_app() { + step "Signing app (hardened runtime, secure timestamp)" + assert_single_executable + codesign --force --options runtime --timestamp \ + --entitlements "${ENTITLEMENTS}" \ + --sign "${SIGN_IDENTITY}" \ + "${APP_BUNDLE}" + codesign --verify --strict --deep --verbose=2 "${APP_BUNDLE}" + codesign -dv --verbose=4 "${APP_BUNDLE}" 2>&1 | grep -E '^(Identifier|Format|CodeDirectory|Authority|Timestamp|TeamIdentifier|Runtime Version)' + step "Gatekeeper assessment of the app (rejected as 'Unnotarized' before notarization)" + spctl -a -vv "${APP_BUNDLE}" 2>&1 || true +} + +create_dmg() { + step "Creating ${DMG_PATH}" + local dmg_root="${DIST_DIR}/dmg-root" + rm -rf "${dmg_root}" "${DMG_PATH}" "${DMG_PATH}.sha256" + mkdir -p "${dmg_root}" + ditto "${APP_BUNDLE}" "${dmg_root}/${APP_NAME}.app" + ln -s /Applications "${dmg_root}/Applications" + hdiutil create -volname "${APP_NAME} ${VERSION}" -srcfolder "${dmg_root}" \ + -fs HFS+ -format UDZO -ov "${DMG_PATH}" + rm -rf "${dmg_root}" + codesign --force --timestamp --sign "${SIGN_IDENTITY}" "${DMG_PATH}" + codesign --verify --strict --verbose=2 "${DMG_PATH}" +} + +notarize() { + if [[ "${DRY_RUN}" == "1" ]]; then + step "DRY_RUN=1: skipping notarization and stapling" + return + fi + local auth=() + if [[ -n "${NOTARY_KEY_PATH:-}" ]]; then + # CI: App Store Connect API key file (never committed). + [[ -n "${NOTARY_KEY_ID:-}" && -n "${NOTARY_ISSUER_ID:-}" ]] \ + || fail "NOTARY_KEY_PATH requires NOTARY_KEY_ID and NOTARY_ISSUER_ID" + auth=(--key "${NOTARY_KEY_PATH}" --key-id "${NOTARY_KEY_ID}" --issuer "${NOTARY_ISSUER_ID}") + step "Notarizing (API key ${NOTARY_KEY_ID})" + else + auth=(--keychain-profile "${NOTARY_PROFILE}") + step "Notarizing (keychain profile ${NOTARY_PROFILE})" + fi + xcrun notarytool submit "${DMG_PATH}" "${auth[@]}" --wait + xcrun stapler staple "${DMG_PATH}" + xcrun stapler validate "${DMG_PATH}" +} + +final_checks() { + step "Gatekeeper assessment of the DMG" + if [[ "${DRY_RUN}" == "1" ]]; then + spctl -a -t open --context context:primary-signature -vv "${DMG_PATH}" 2>&1 || true + else + spctl -a -t open --context context:primary-signature -vv "${DMG_PATH}" + fi + step "Checksum" + (cd "${DIST_DIR}" && shasum -a 256 "$(basename "${DMG_PATH}")" > "$(basename "${DMG_PATH}").sha256") + cat "${DMG_PATH}.sha256" +} + +preflight +build_app +sign_app +create_dmg +notarize +final_checks +step "Done: ${DMG_PATH}" diff --git a/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift b/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift index 99c243f..5cadab0 100644 --- a/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift +++ b/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift @@ -139,7 +139,8 @@ public struct CLICommandRunner { } private static func printVersion() { - // TODO(TASK-009): share the bundle version with the SwiftPM executable target. + // Scripts/build_app.sh writes the repo-root VERSION file into Info.plist, so the + // bundled app reports the release version. A bare `swift run` has no Info.plist. let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..fd2a018 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +3.1.0 diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..b993661 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,100 @@ +# Releasing MacOptimizer + +MacOptimizer ships as a Developer ID-signed, notarized DMG on GitHub Releases. It is not +sandboxed and is not distributed through the Mac App Store. + +## Version + +The `VERSION` file at the repo root is the only version source (`MAJOR.MINOR.PATCH`). +`Scripts/build_app.sh` writes it to `CFBundleShortVersionString`, and writes +`git rev-list --count HEAD` to `CFBundleVersion`. The CLI `version` command reads the bundle, +and the release tag must be `v` (the release workflow checks this). + +To release a new version: bump `VERSION`, update `CHANGELOG.md`, merge, then follow the local +flow below. + +## App icon + +Replace `Resources/Brand/AppIcon-1024.png` (a 1024×1024 PNG) to change the icon. +`Scripts/build_icns.sh` renders every iconset size from it and runs `iconutil` during each +build. The current master is a placeholder drawn by `Scripts/make_icon.swift`, which runs +only when the master file is missing. + +## Local release (current flow) + +Requirements on the release Mac: + +- The signing identity `Developer ID Application: OSMAN CAGRI GENC (N83DXM47FV)` in the login + keychain. +- A notarytool keychain profile named `codifya-notary` + (`xcrun notarytool store-credentials`). + +```bash +# 1. Rehearsal: builds, signs and packages; skips notarization. +DRY_RUN=1 ./Scripts/release.sh + +# 2. Real release, from a clean tree on main. +./Scripts/release.sh + +# 3. Publish. +git tag v$(cat VERSION) && git push origin v$(cat VERSION) +gh release create v$(cat VERSION) dist/MacOptimizer-$(cat VERSION).dmg \ + dist/MacOptimizer-$(cat VERSION).dmg.sha256 --generate-notes +``` + +`Scripts/release.sh` does the following: + +1. Builds a universal (arm64 + x86_64) bundle into `dist/stage/` (`UNIVERSAL=0` for the host + architecture only). +2. Checks that the bundle contains exactly one Mach-O file. The SwiftPM resource bundle + (localizations) is data, so the app is signed once, without `--deep`. +3. Signs with `codesign --force --options runtime --timestamp --entitlements + Scripts/MacOptimizer.entitlements`, verifies with `codesign --verify --strict --deep`, + and runs `spctl -a -vv`. Before notarization, `spctl` reports "Unnotarized Developer ID". + That is expected. +4. Creates `dist/MacOptimizer-.dmg` (the app plus an `/Applications` link) and signs + the DMG. +5. Submits the DMG once with `notarytool submit --wait`, then staples and validates the + ticket on the DMG. Notarizing the DMG also notarizes the app inside it. Gatekeeper checks + the copied app online on first launch. +6. Runs `spctl -a -t open --context context:primary-signature -vv` on the DMG and writes + `dist/MacOptimizer-.dmg.sha256`. + +Overrides: `SIGN_IDENTITY`, `NOTARY_PROFILE`, `SCRATCH_PATH`, and `ALLOW_DIRTY=1`. A real +release refuses to run from a dirty tree unless `ALLOW_DIRTY=1` is set. `dist/` is ignored +by git. + +## Entitlements + +`Scripts/MacOptimizer.entitlements` is intentionally empty. Hardened runtime comes from +`--options runtime`. The app is pure Swift with no JIT, plugins, third-party dylibs, Apple +Events or DYLD variables, so it needs no hardened-runtime exceptions. Keychain access to its +own items and outbound HTTPS both work without entitlements outside the sandbox. Add an +entitlement only for a need you have reproduced, and record the reason in the file's comment. + +## CI (`.github/workflows/release.yml`) + +On a `v*` tag: + +- **build-unsigned** always runs. It checks that the tag matches `VERSION`, builds a + universal unsigned DMG, and uploads it as a workflow artifact. It does not attach the DMG + to a release. +- **sign-and-notarize** runs only when all of the following repository secrets exist. + Without them, the job is skipped and the workflow still succeeds. + +| Secret | Content | +|---|---| +| `MACOS_CERT_P12` | Base64 of the exported Developer ID Application certificate + private key (`base64 -i cert.p12`) | +| `MACOS_CERT_PASSWORD` | Password of that `.p12` | +| `ASC_KEY_ID` | App Store Connect API key ID | +| `ASC_ISSUER_ID` | App Store Connect issuer ID | +| `ASC_KEY_P8` | Full text of the `.p8` API key | + + The job imports the certificate into a temporary keychain and writes the `.p8` to the + runner temp directory with mode 600. It then runs `Scripts/release.sh`, which notarizes + with `--key/--key-id/--issuer`, and creates a **draft** GitHub release with the notarized + DMG and its checksum. The temporary keychain and key files are deleted even when the job + fails. Use a Developer-role API key for CI, not an Admin one. + +`ci.yml` builds the universal bundle on every PR. It checks the version, the icon, both +`.lproj` folders and both architectures.