Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
152 changes: 130 additions & 22 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Binary file added Resources/Brand/AppIcon-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions Scripts/MacOptimizer.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
Entitlements for the Developer ID (non-App Store) build of MacOptimizer.

- Hardened runtime is enabled by the codesign "runtime" option in Scripts/release.sh,
not by this file.
- The app is NOT sandboxed (com.apple.security.app-sandbox is absent): it inspects and
cleans files across the user's Library, which a sandbox would not allow.
- No hardened-runtime exceptions are granted. The app is pure Swift with no JIT, no
unsigned executable memory, no dynamically loaded third-party libraries, no plugins,
and no DYLD environment variables, so none of
com.apple.security.cs.{allow-jit, allow-unsigned-executable-memory,
disable-library-validation, allow-dyld-environment-variables, disable-executable-page-protection}
is needed. Keychain access (own items) and outbound network requests (AI providers)
are allowed for unsandboxed hardened-runtime apps without entitlements.
- Add an entitlement here only with a documented, reproduced need (see docs/RELEASING.md).
-->
<plist version="1.0">
<dict/>
</plist>
79 changes: 60 additions & 19 deletions Scripts/build_app.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF > "${CONTENTS_DIR}/Info.plist"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
Expand All @@ -35,7 +72,7 @@ cat <<EOF > "${CONTENTS_DIR}/Info.plist"
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>com.osmancagrigenc.MacOptimizer</string>
<string>${BUNDLE_ID}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
Expand All @@ -45,25 +82,29 @@ cat <<EOF > "${CONTENTS_DIR}/Info.plist"
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>${BUILD_NUMBER}</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>tr</string>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<false/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 MacOptimizer. All rights reserved.</string>
<string>Copyright © 2026 Osman Cagri Genc (Codifya). Licensed under Apache-2.0.</string>
</dict>
</plist>
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})."
Loading
Loading