chore: import upstream snapshot with attribution
Build / Build and test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:18:38 +08:00
commit 1d1286fadb
1468 changed files with 272754 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
name: Build
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PROJECT_NAME: MacTools
SCHEME: MacTools
DERIVED_DATA: build/DerivedData
CI_BUNDLE_IDENTIFIER_PREFIX: dev.mactools.ci
jobs:
build:
name: Build and test
runs-on: macos-26
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Show Xcode version
run: xcodebuild -version
- name: Install XcodeGen
run: |
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
- name: Write CI local config
run: |
cat > LocalConfig.xcconfig <<EOF
DEVELOPMENT_TEAM =
BUNDLE_IDENTIFIER_PREFIX = ${CI_BUNDLE_IDENTIFIER_PREFIX}
EOF
- name: Generate Xcode project
run: make generate
- name: Build and run tests
run: |
xcodebuild \
-project "${PROJECT_NAME}.xcodeproj" \
-scheme "${SCHEME}" \
-configuration Debug \
-destination "platform=macOS" \
-derivedDataPath "${DERIVED_DATA}" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY= \
test \
-quiet
- name: Package Debug app artifact
if: github.event_name == 'workflow_dispatch'
run: |
set -euo pipefail
PRODUCTS_DIR="${DERIVED_DATA}/Build/Products/Debug"
APP_PATH="${PRODUCTS_DIR}/MacTools Dev.app"
ARTIFACT_ROOT="build/debug-artifact/MacTools-Debug"
find "$PRODUCTS_DIR" -maxdepth 1 -name "*.bundle" -type d -print0 | while IFS= read -r -d '' bundle; do
codesign --force --deep --sign - "$bundle"
codesign --verify --deep --strict "$bundle"
done
if [[ -d "$APP_PATH/Contents/Frameworks" ]]; then
find "$APP_PATH/Contents/Frameworks" -type d \( -name "*.framework" -o -name "*.xpc" \) -print0 | while IFS= read -r -d '' item; do
codesign --force --deep --sign - "$item"
done
fi
codesign --force --deep --sign - --entitlements Configs/MacTools.entitlements "$APP_PATH"
codesign --verify --deep --strict "$APP_PATH"
./scripts/plugins/sync-debug-plugins.sh \
--source-dir Plugins \
--products-dir "$PRODUCTS_DIR" \
--output-dir build/LocalPlugins \
--skip-install
rm -rf build/debug-artifact
mkdir -p "$ARTIFACT_ROOT"
ditto "$APP_PATH" "$ARTIFACT_ROOT/MacTools Dev.app"
ditto build/LocalPlugins "$ARTIFACT_ROOT/LocalPlugins"
cat > "$ARTIFACT_ROOT/run-debug.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
LOCAL_CATALOG_PATH="$ROOT/LocalPlugins/catalog.local.json"
PACKAGES_DIR="$ROOT/LocalPlugins/Packages"
INSTALL_DIR="$HOME/Library/Application Support/MacTools Dev/Plugins/Installed"
/usr/bin/ruby -rjson -ruri -e '
root = ARGV.fetch(0)
catalog_path = File.join(root, "LocalPlugins", "catalog.dev.json")
output_path = File.join(root, "LocalPlugins", "catalog.local.json")
catalog = JSON.parse(File.read(catalog_path))
(catalog["plugins"] || []).each do |plugin|
package = plugin["package"] || {}
package_path = File.expand_path(File.join(root, "LocalPlugins", "Packages", "#{plugin.fetch("id")}.mactoolsplugin"))
package["url"] = URI::DEFAULT_PARSER.escape("file://#{package_path}")
end
File.write(output_path, JSON.pretty_generate(catalog))
' "$ROOT"
mkdir -p "$INSTALL_DIR"
find "$PACKAGES_DIR" -maxdepth 1 -name "*.mactoolsplugin" -type d -print0 | while IFS= read -r -d '' package; do
destination="$INSTALL_DIR/$(basename "$package")"
staging="$INSTALL_DIR/.$(basename "$package").syncing.$$"
rm -rf "$staging"
ditto "$package" "$staging"
rm -rf "$destination"
mv "$staging" "$destination"
done
LOCAL_CATALOG_URL="$(/usr/bin/ruby -ruri -e 'puts URI::DEFAULT_PARSER.escape("file://#{File.expand_path(ARGV.fetch(0))}")' "$LOCAL_CATALOG_PATH")"
export MACTOOLS_PLUGIN_CATALOG_URL="$LOCAL_CATALOG_URL"
open "$ROOT/MacTools Dev.app"
EOF
chmod +x "$ARTIFACT_ROOT/run-debug.sh"
cat > "$ARTIFACT_ROOT/README.txt" <<'EOF'
This is an unsigned Debug build for local testing.
Run ./run-debug.sh to launch MacTools Dev with the bundled local plugin catalog.
If macOS blocks the app because it was downloaded from the internet, remove quarantine:
xattr -dr com.apple.quarantine "MacTools Dev.app"
Then run ./run-debug.sh again.
EOF
(cd build/debug-artifact && ditto -c -k --sequesterRsrc --keepParent MacTools-Debug MacTools-Debug.zip)
- name: Upload Debug app artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: MacTools-Debug
path: build/debug-artifact/MacTools-Debug.zip
if-no-files-found: error
- name: Build unsigned Release app
if: github.event_name != 'pull_request'
run: |
xcodebuild \
-project "${PROJECT_NAME}.xcodeproj" \
-scheme "${SCHEME}" \
-configuration Release \
-destination "generic/platform=macOS" \
-derivedDataPath "${DERIVED_DATA}" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY= \
build \
-quiet
+107
View File
@@ -0,0 +1,107 @@
name: Homebrew Cask Update
on:
workflow_dispatch:
inputs:
version:
description: "Version to bump, for example 1.0.14. Defaults to the latest stable release."
required: false
type: string
permissions:
contents: read
jobs:
bump-official-cask:
name: Open Homebrew cask bump PR
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: macos-26
timeout-minutes: 15
steps:
- name: Resolve release metadata
id: release
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [[ -n "$INPUT_VERSION" ]]; then
TAG="v${INPUT_VERSION#v}"
release="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")"
else
release="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest")"
fi
if [[ -z "$release" || "$release" == "null" ]]; then
echo "No stable release found." >&2
exit 1
fi
TAG="$(printf '%s' "$release" | jq -r '.tag_name')"
VERSION="${TAG#v}"
DOWNLOAD_URL="$(printf '%s' "$release" | jq -r \
'.assets[] | select(.name == "MacTools.dmg") | .browser_download_url')"
SHA256_URL="$(printf '%s' "$release" | jq -r \
'.assets[] | select(.name == "MacTools.sha256") | .browser_download_url')"
if [[ -z "$DOWNLOAD_URL" || "$DOWNLOAD_URL" == "null" ]]; then
echo "MacTools.dmg asset not found in release ${TAG}." >&2
exit 1
fi
if [[ -z "$SHA256_URL" || "$SHA256_URL" == "null" ]]; then
echo "MacTools.sha256 asset not found in release ${TAG}." >&2
exit 1
fi
SHA256="$(curl -fsSL "$SHA256_URL" | awk '{ print $1; exit }')"
if [[ ! "$SHA256" =~ ^[0-9a-fA-F]{64}$ ]]; then
echo "Invalid SHA256 read from ${SHA256_URL}: ${SHA256}" >&2
exit 1
fi
{
echo "tag=$TAG"
echo "version=$VERSION"
echo "sha256=$SHA256"
} >> "$GITHUB_OUTPUT"
- name: Check official cask version
id: cask
env:
HOMEBREW_NO_INSTALL_FROM_API: 1
run: |
brew update
current="$(brew info --cask --json=v2 mactools | jq -r '.casks[0].version')"
latest="${{ steps.release.outputs.version }}"
{
echo "current=$current"
echo "latest=$latest"
if [[ "$current" == "$latest" ]]; then
echo "up_to_date=true"
else
echo "up_to_date=false"
fi
} >> "$GITHUB_OUTPUT"
- name: Open Homebrew/homebrew-cask bump PR
if: steps.cask.outputs.up_to_date == 'false'
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
HOMEBREW_NO_INSTALL_FROM_API: 1
run: |
if [[ -z "$HOMEBREW_GITHUB_API_TOKEN" ]]; then
echo "Missing HOMEBREW_GITHUB_API_TOKEN secret. Create a GitHub token with public_repo access." >&2
exit 1
fi
brew bump-cask-pr mactools \
--version "${{ steps.release.outputs.version }}" \
--sha256 "${{ steps.release.outputs.sha256 }}" \
--no-browse
- name: Already up to date
if: steps.cask.outputs.up_to_date == 'true'
run: |
echo "Official Homebrew cask is already at version ${{ steps.cask.outputs.current }}."
+68
View File
@@ -0,0 +1,68 @@
name: Deploy Pages
on:
workflow_run:
workflows:
- Release
- Plugin Release
types:
- completed
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy site to GitHub Pages
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout main
uses: actions/checkout@v5
with:
ref: main
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: site/package-lock.json
- name: Install site dependencies
working-directory: site
run: npm ci
- name: Build site
working-directory: site
run: npm run build
- name: Prepare Pages artifact
run: |
rm -rf build/pages
mkdir -p build/pages
cp -R docs/. build/pages/
cp -R site/dist/. build/pages/
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: build/pages
- name: Deploy GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+418
View File
@@ -0,0 +1,418 @@
name: Plugin Release
on:
push:
tags:
- "plugins-*"
workflow_dispatch:
inputs:
tag:
description: "Plugin batch tag, for example plugins-2026.05.17 or plugins-1.0.0"
required: true
type: string
mode:
description: "Plugin release mode"
required: false
default: auto
type: choice
options:
- auto
- selected
- all
plugins:
description: "Comma-separated plugin IDs or directory names when mode=selected"
required: false
type: string
prerelease:
description: "Mark the GitHub Release as prerelease"
required: false
default: false
type: boolean
permissions:
contents: write
concurrency:
group: plugin-release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
env:
PLUGIN_SOURCE_DIR: Plugins
PLUGIN_RELEASE_ROOT: build/plugin-release
PLUGIN_BUILD_DIR: build/plugin-release/Build
PLUGIN_ASSETS_DIR: build/plugin-release/Assets
PLUGIN_PREVIOUS_CATALOG_PATH: build/plugin-release/previous-catalog.json
PLUGIN_RELEASE_PLAN_PATH: build/plugin-release/plan.json
PLUGIN_CATALOG_DELTA_PATH: build/plugin-release/catalog.delta.json
PLUGIN_CATALOG_PATH: build/plugin-release/catalog.json
jobs:
plugin-release:
name: Build, sign, and release plugins
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- name: Set plugin release metadata
run: |
TAG="${{ inputs.tag || github.ref_name }}"
if [[ ! "$TAG" =~ ^plugins-[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then
echo "Plugin release tag must look like plugins-2026.05.17 or plugins-1.0.0" >&2
exit 1
fi
MODE="${{ inputs.mode || 'auto' }}"
PLUGIN_SELECTION="${{ inputs.plugins || '' }}"
if [[ "$MODE" != "selected" && -n "$PLUGIN_SELECTION" ]]; then
echo "The plugins input is only valid when mode=selected." >&2
exit 1
fi
PRERELEASE="${{ inputs.prerelease || 'false' }}"
RELEASE_SUFFIX="${TAG#plugins-}"
PLUGIN_KIT_VERSION="$(python3 - <<'PY'
import json
from pathlib import Path
versions = {
json.loads(path.read_text(encoding="utf-8"))["pluginKitVersion"]
for path in sorted(Path("Plugins").glob("*/plugin.json"))
}
if len(versions) != 1:
raise SystemExit("All plugin manifests must use one pluginKitVersion.")
print(next(iter(versions)))
PY
)"
if [[ "$PLUGIN_KIT_VERSION" == "2" ]]; then
PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/catalog.json"
else
PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/v${PLUGIN_KIT_VERSION}/catalog.json"
fi
{
echo "TAG=$TAG"
echo "PLUGIN_RELEASE_MODE=$MODE"
echo "PLUGIN_RELEASE_SELECTION=$PLUGIN_SELECTION"
echo "PLUGIN_RELEASE_TITLE=MacTools Plugins ${RELEASE_SUFFIX}"
echo "PLUGIN_RELEASE_NOTES_URL=https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "PLUGIN_ASSET_BASE_URL=https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
echo "PLUGIN_PRERELEASE=$PRERELEASE"
echo "PLUGIN_KIT_VERSION=$PLUGIN_KIT_VERSION"
echo "PLUGIN_CATALOG_RELATIVE_PATH=$PLUGIN_CATALOG_RELATIVE_PATH"
echo "SIGNED_PLUGIN_CATALOG_PATH=$PLUGIN_CATALOG_RELATIVE_PATH"
} >> "$GITHUB_ENV"
- name: Prepare previous plugin catalog
run: |
mkdir -p "$PLUGIN_RELEASE_ROOT"
git fetch --force --tags origin +refs/heads/main:refs/remotes/origin/main
if git show "origin/main:${PLUGIN_CATALOG_RELATIVE_PATH}" > "$PLUGIN_PREVIOUS_CATALOG_PATH" 2>/dev/null; then
echo "Using ${PLUGIN_CATALOG_RELATIVE_PATH} from origin/main as the previous production catalog."
elif PREVIOUS_VERSIONED_CATALOG="$(git ls-tree -r --name-only origin/main docs/plugins | \
python3 -c 'import re,sys; current=int(sys.argv[1]); paths=[path.strip() for path in sys.stdin if re.fullmatch(r"docs/plugins/v\d+/catalog\.json", path.strip())]; candidates=[(int(re.search(r"/v(\d+)/", path).group(1)), path) for path in paths if int(re.search(r"/v(\d+)/", path).group(1)) < current]; print(max(candidates)[1] if candidates else "")' \
"$PLUGIN_KIT_VERSION")" && \
[[ -n "$PREVIOUS_VERSIONED_CATALOG" ]] && \
git show "origin/main:${PREVIOUS_VERSIONED_CATALOG}" > "$PLUGIN_PREVIOUS_CATALOG_PATH" 2>/dev/null; then
echo "Using ${PREVIOUS_VERSIONED_CATALOG} from origin/main as the ABI migration baseline."
elif [[ "$PLUGIN_CATALOG_RELATIVE_PATH" != "docs/plugins/catalog.json" ]] && \
git show origin/main:docs/plugins/catalog.json > "$PLUGIN_PREVIOUS_CATALOG_PATH" 2>/dev/null; then
echo "Using the legacy v2 catalog from origin/main as the v${PLUGIN_KIT_VERSION} release baseline."
elif [[ -f "$SIGNED_PLUGIN_CATALOG_PATH" ]]; then
cp "$SIGNED_PLUGIN_CATALOG_PATH" "$PLUGIN_PREVIOUS_CATALOG_PATH"
echo "Using ${PLUGIN_CATALOG_RELATIVE_PATH} from the checked-out ref as the previous production catalog."
else
rm -f "$PLUGIN_PREVIOUS_CATALOG_PATH"
echo "No previous plugin catalog found; auto mode will publish all plugins."
fi
- name: Plan incremental plugin release
run: |
plan_args=(
--mode "$PLUGIN_RELEASE_MODE"
--source-dir "$PLUGIN_SOURCE_DIR"
--previous-catalog "$PLUGIN_PREVIOUS_CATALOG_PATH"
--output "$PLUGIN_RELEASE_PLAN_PATH"
)
if [[ -n "$PLUGIN_RELEASE_SELECTION" ]]; then
plan_args+=(--plugins "$PLUGIN_RELEASE_SELECTION")
fi
scripts/plugins/plan-plugin-release.py "${plan_args[@]}"
python3 <<'PY'
import json
import os
from pathlib import Path
plan = json.loads(Path(os.environ["PLUGIN_RELEASE_PLAN_PATH"]).read_text(encoding="utf-8"))
selected = plan.get("selectedPluginIDs", [])
removed = plan.get("removedPluginIDs", [])
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env:
env.write(f"PLUGIN_RELEASE_REQUIRED={'true' if plan.get('releaseRequired') else 'false'}\n")
env.write(f"PLUGIN_CHANGED_COUNT={len(selected)}\n")
env.write(f"PLUGIN_REMOVED_COUNT={len(removed)}\n")
env.write(f"PLUGIN_FULL_RELEASE={'true' if plan.get('fullRelease') else 'false'}\n")
planned_version = plan.get("pluginKitVersion")
if str(planned_version) != os.environ["PLUGIN_KIT_VERSION"]:
raise SystemExit(
f"Release plan PluginKit {planned_version} does not match manifest PluginKit "
f"{os.environ['PLUGIN_KIT_VERSION']}."
)
lines = [
"## Plugin release plan",
"",
f"- Mode: `{plan.get('mode')}`",
f"- Release required: `{str(plan.get('releaseRequired')).lower()}`",
f"- Full release: `{str(plan.get('fullRelease')).lower()}`",
f"- Updated packages: `{len(selected)}`",
f"- Removed plugins: `{len(removed)}`",
]
if selected:
lines.extend(["", "### Packages to build"])
for plugin in plan.get("plugins", []):
reason = "; ".join(plan.get("reasons", {}).get(plugin["id"], []))
suffix = f" - {reason}" if reason else ""
lines.append(f"- `{plugin['id']}` {plugin['version']}{suffix}")
if removed:
lines.extend(["", "### Removed from catalog"])
lines.extend(f"- `{plugin_id}`" for plugin_id in removed)
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
summary.write("\n".join(lines) + "\n")
PY
- name: No plugin release changes
if: env.PLUGIN_RELEASE_REQUIRED != 'true'
run: echo "No plugin package or catalog changes were detected for $TAG."
- name: Validate required secrets
if: env.PLUGIN_RELEASE_REQUIRED == 'true'
env:
APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }}
BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }}
DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
PLUGIN_CATALOG_PRIVATE_KEY_BASE64: ${{ secrets.PLUGIN_CATALOG_PRIVATE_KEY_BASE64 }}
run: |
missing=0
if [[ -z "$PLUGIN_CATALOG_PRIVATE_KEY_BASE64" ]]; then
echo "Missing required secret: PLUGIN_CATALOG_PRIVATE_KEY_BASE64" >&2
missing=1
fi
if [[ "$PLUGIN_CHANGED_COUNT" != "0" ]]; then
for name in \
APPLE_DEVELOPMENT_TEAM \
BUNDLE_IDENTIFIER_PREFIX \
DEVELOPER_ID_CERT_P12 \
DEVELOPER_ID_CERT_PASSWORD; do
if [[ -z "${!name}" ]]; then
echo "Missing required secret: $name" >&2
missing=1
fi
done
fi
exit "$missing"
- name: Show Xcode version
if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0'
run: xcodebuild -version
- name: Install XcodeGen
if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0'
run: |
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
- name: Install catalog signing dependency
if: env.PLUGIN_RELEASE_REQUIRED == 'true'
run: |
python3 -m venv "$RUNNER_TEMP/plugin-signing-venv"
"$RUNNER_TEMP/plugin-signing-venv/bin/python" -m pip install --upgrade pip
"$RUNNER_TEMP/plugin-signing-venv/bin/python" -m pip install cryptography
echo "$RUNNER_TEMP/plugin-signing-venv/bin" >> "$GITHUB_PATH"
- name: Write release local config
if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0'
env:
APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }}
BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }}
run: |
umask 077
cat > LocalConfig.xcconfig <<EOF
DEVELOPMENT_TEAM = ${APPLE_DEVELOPMENT_TEAM}
BUNDLE_IDENTIFIER_PREFIX = ${BUNDLE_IDENTIFIER_PREFIX}
EOF
- name: Import Developer ID certificate
if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0'
env:
DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/developer-id.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 24)"
echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" \
-k "$KEYCHAIN_PATH" \
-P "$DEVELOPER_ID_CERT_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$KEYCHAIN_PASSWORD" \
"$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH"
SIGNING_IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | awk -F'"' '/Developer ID Application/ { print $2; exit }')"
if [[ -z "$SIGNING_IDENTITY" ]]; then
echo "Developer ID Application identity was not found in imported certificate." >&2
exit 1
fi
rm -f "$CERT_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV"
- name: Generate Xcode project
if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0'
run: make generate
- name: Build plugin release assets
if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0'
run: |
selected_plugins=()
while IFS= read -r plugin_id; do
[[ -n "$plugin_id" ]] && selected_plugins+=("$plugin_id")
done < <(python3 - <<'PY'
import json
import os
from pathlib import Path
plan = json.loads(Path(os.environ["PLUGIN_RELEASE_PLAN_PATH"]).read_text(encoding="utf-8"))
for plugin_id in plan.get("selectedPluginIDs", []):
print(plugin_id)
PY
)
build_args=(
--source-dir "$PLUGIN_SOURCE_DIR"
--build-dir "$PLUGIN_BUILD_DIR"
--dist-dir "$PLUGIN_RELEASE_ROOT"
--assets-dir "$PLUGIN_ASSETS_DIR"
--base-url "$PLUGIN_ASSET_BASE_URL"
--catalog-output "$PLUGIN_CATALOG_DELTA_PATH"
--sign-identity "$SIGNING_IDENTITY"
--destination "generic/platform=macOS"
--xcodebuild "$PWD/scripts/xcodebuild-filtered.sh"
--release-notes-url "$PLUGIN_RELEASE_NOTES_URL"
)
for plugin_id in "${selected_plugins[@]}"; do
build_args+=(--plugin "$plugin_id")
done
CODE_SIGN_KEYCHAIN="$KEYCHAIN_PATH" scripts/plugins/build-plugin-release-assets.sh "${build_args[@]}"
- name: Merge and sign plugin catalog
if: env.PLUGIN_RELEASE_REQUIRED == 'true'
env:
PLUGIN_CATALOG_PRIVATE_KEY_BASE64: ${{ secrets.PLUGIN_CATALOG_PRIVATE_KEY_BASE64 }}
run: |
scripts/plugins/merge-plugin-catalog.py \
--previous "$PLUGIN_PREVIOUS_CATALOG_PATH" \
--updates "$PLUGIN_CATALOG_DELTA_PATH" \
--plan "$PLUGIN_RELEASE_PLAN_PATH" \
--plugin-kit-version "$PLUGIN_KIT_VERSION" \
--output "$PLUGIN_CATALOG_PATH"
scripts/plugins/sign-plugin-catalog.sh \
--input "$PLUGIN_CATALOG_PATH" \
--output "$SIGNED_PLUGIN_CATALOG_PATH" \
--private-key-base64 "$PLUGIN_CATALOG_PRIVATE_KEY_BASE64"
- name: Generate plugin release notes
if: env.PLUGIN_RELEASE_REQUIRED == 'true'
run: |
RELEASE_NOTES="$RUNNER_TEMP/plugin-release-notes.md"
python3 scripts/changelog.py extract \
--tag "$TAG" \
--output "$RELEASE_NOTES"
echo "PLUGIN_RELEASE_NOTES_PATH=$RELEASE_NOTES" >> "$GITHUB_ENV"
- name: Create or update plugin GitHub Release
if: env.PLUGIN_RELEASE_REQUIRED == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
shopt -s nullglob
assets=("$PLUGIN_ASSETS_DIR"/*.mactoolsplugin.zip)
publish_args=(
--repo "$GITHUB_REPOSITORY"
--tag "$TAG"
--title "$PLUGIN_RELEASE_TITLE"
--notes-file "$PLUGIN_RELEASE_NOTES_PATH"
--allow-empty
)
if [[ "$PLUGIN_PRERELEASE" == "true" ]]; then
publish_args+=(--prerelease)
fi
for asset in "${assets[@]}"; do
publish_args+=(--asset "$asset")
done
scripts/plugins/publish-plugin-release.sh "${publish_args[@]}"
- name: Upload plugin release artifact
if: env.PLUGIN_RELEASE_REQUIRED == 'true'
uses: actions/upload-artifact@v4
with:
name: MacTools-${{ env.TAG }}
path: |
${{ env.PLUGIN_ASSETS_DIR }}/*.mactoolsplugin.zip
${{ env.PLUGIN_RELEASE_PLAN_PATH }}
${{ env.PLUGIN_CATALOG_PATH }}
${{ env.SIGNED_PLUGIN_CATALOG_PATH }}
if-no-files-found: error
retention-days: 30
- name: Commit plugin catalog to main
if: env.PLUGIN_RELEASE_REQUIRED == 'true'
run: |
cp "$SIGNED_PLUGIN_CATALOG_PATH" "$RUNNER_TEMP/plugin-catalog.json"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout -B main origin/main
mkdir -p "$(dirname "$PLUGIN_CATALOG_RELATIVE_PATH")"
cp "$RUNNER_TEMP/plugin-catalog.json" "$PLUGIN_CATALOG_RELATIVE_PATH"
git add "$PLUGIN_CATALOG_RELATIVE_PATH"
if git diff --cached --quiet; then
echo "No plugin catalog changes to commit."
else
git commit -m "ci: update plugin catalog for ${TAG}"
git push origin main
fi
- name: Cleanup keychain
if: always()
run: |
if [[ -n "${KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true
fi
+208
View File
@@ -0,0 +1,208 @@
name: Prepare Release
on:
workflow_dispatch:
inputs:
type:
description: "Release type"
required: true
default: app
type: choice
options:
- app
- plugin
version:
description: "Target version without prefix, for example 1.0.7"
required: true
type: string
release:
description: "Trigger release workflow after tag is pushed"
required: true
default: true
type: boolean
plugin_mode:
description: "Plugin release mode"
required: false
default: auto
type: choice
options:
- auto
- selected
- all
plugins:
description: "Comma-separated plugin IDs or directory names when plugin_mode=selected"
required: false
type: string
permissions:
contents: write
actions: write
concurrency:
group: prepare-release-${{ inputs.type }}
cancel-in-progress: false
jobs:
prepare:
name: Prepare ${{ inputs.type }} release
runs-on: macos-26
timeout-minutes: 45
env:
CI_BUNDLE_IDENTIFIER_PREFIX: dev.mactools.ci
steps:
- name: Checkout main
uses: actions/checkout@v5
with:
ref: main
fetch-depth: 0
- name: Ensure main branch
run: git checkout main
- name: Validate inputs
env:
INPUT_TYPE: ${{ inputs.type }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLUGIN_MODE: ${{ inputs.plugin_mode }}
INPUT_PLUGINS: ${{ inputs.plugins }}
run: |
set -euo pipefail
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "version must look like 1.2.3 and must not include v or plugins-." >&2
exit 1
fi
if [[ "$INPUT_TYPE" == "app" ]]; then
if [[ "$INPUT_PLUGIN_MODE" != "auto" || -n "$INPUT_PLUGINS" ]]; then
echo "plugin_mode/plugins are only valid for plugin releases." >&2
exit 1
fi
elif [[ "$INPUT_TYPE" == "plugin" ]]; then
if [[ "$INPUT_PLUGIN_MODE" != "selected" && -n "$INPUT_PLUGINS" ]]; then
echo "plugins input is only valid when plugin_mode=selected." >&2
exit 1
fi
if [[ "$INPUT_PLUGIN_MODE" == "selected" && -z "$INPUT_PLUGINS" ]]; then
echo "plugins input is required when plugin_mode=selected." >&2
exit 1
fi
else
echo "Unknown type: $INPUT_TYPE" >&2
exit 1
fi
- name: Show Xcode version
run: xcodebuild -version
- name: Install XcodeGen
run: |
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
- name: Write CI local config
run: |
cat > LocalConfig.xcconfig <<EOF
DEVELOPMENT_TEAM =
BUNDLE_IDENTIFIER_PREFIX = ${CI_BUNDLE_IDENTIFIER_PREFIX}
EOF
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Prepare app release
if: inputs.type == 'app'
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
scripts/release.py \
--type app \
--version "$INPUT_VERSION" \
--yes
{
echo "RELEASE_TAG=v${INPUT_VERSION}"
echo "RELEASE_WORKFLOW=release.yml"
} >> "$GITHUB_ENV"
- name: Prepare plugin release
if: inputs.type == 'plugin'
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLUGIN_MODE: ${{ inputs.plugin_mode }}
INPUT_PLUGINS: ${{ inputs.plugins }}
run: |
set -euo pipefail
release_args=(
--type plugin
--version "$INPUT_VERSION"
--plugin-mode "$INPUT_PLUGIN_MODE"
--yes
)
if [[ -n "$INPUT_PLUGINS" ]]; then
release_args+=(--plugin "$INPUT_PLUGINS")
fi
scripts/release.py "${release_args[@]}"
{
echo "RELEASE_TAG=plugins-${INPUT_VERSION}"
echo "RELEASE_WORKFLOW=plugin-release.yml"
} >> "$GITHUB_ENV"
- name: Write summary
env:
INPUT_TYPE: ${{ inputs.type }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_RELEASE: ${{ inputs.release }}
INPUT_PLUGIN_MODE: ${{ inputs.plugin_mode }}
INPUT_PLUGINS: ${{ inputs.plugins }}
run: |
{
echo "## Prepare release"
echo ""
echo "- Type: \`${INPUT_TYPE}\`"
echo "- Version: \`${INPUT_VERSION}\`"
echo "- Tag: \`${RELEASE_TAG}\`"
echo "- Trigger release workflow: \`${INPUT_RELEASE}\`"
if [[ "$INPUT_TYPE" == "plugin" ]]; then
echo "- Plugin mode: \`${INPUT_PLUGIN_MODE}\`"
if [[ -n "$INPUT_PLUGINS" ]]; then
echo "- Plugins: \`${INPUT_PLUGINS}\`"
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Trigger release workflow
if: inputs.release
env:
GH_TOKEN: ${{ github.token }}
INPUT_TYPE: ${{ inputs.type }}
INPUT_PLUGIN_MODE: ${{ inputs.plugin_mode }}
INPUT_PLUGINS: ${{ inputs.plugins }}
run: |
set -euo pipefail
if [[ "$INPUT_TYPE" == "app" ]]; then
gh workflow run "$RELEASE_WORKFLOW" --ref main -f tag="$RELEASE_TAG"
else
workflow_args=(
--ref main
-f tag="$RELEASE_TAG"
-f mode="$INPUT_PLUGIN_MODE"
-f prerelease=false
)
if [[ -n "$INPUT_PLUGINS" ]]; then
workflow_args+=(-f plugins="$INPUT_PLUGINS")
fi
gh workflow run "$RELEASE_WORKFLOW" "${workflow_args[@]}"
fi
echo "Triggered ${RELEASE_WORKFLOW} for ${RELEASE_TAG}."
+586
View File
@@ -0,0 +1,586 @@
name: Release
on:
push:
tags:
- "v*.*.*"
- "v*.*.*-*"
workflow_dispatch:
inputs:
tag:
description: "Tag to release, for example v0.9.2 or v0.9.2-beta.1"
required: true
type: string
permissions:
contents: write
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
env:
PROJECT_NAME: MacTools
SCHEME: MacTools
DERIVED_DATA: build/DerivedData
ARTIFACT_DIR: build/release
jobs:
release:
name: Build, sign, notarize, and release
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0
- name: Set release metadata
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Release tag must look like v1.2.3 or v1.2.3-beta.1" >&2
exit 1
fi
VERSION="$(awk '$1 == "MARKETING_VERSION" && $2 == "=" { print $3; exit }' Configs/AppVersion.xcconfig)"
BUILD_NUMBER="$(awk '$1 == "CURRENT_PROJECT_VERSION" && $2 == "=" { print $3; exit }' Configs/AppVersion.xcconfig)"
TAG_VERSION="${TAG#v}"
if [[ -z "$VERSION" || -z "$BUILD_NUMBER" ]]; then
echo "MARKETING_VERSION and CURRENT_PROJECT_VERSION must be set in Configs/AppVersion.xcconfig" >&2
exit 1
fi
if [[ "$TAG_VERSION" != "$VERSION" ]]; then
echo "Release tag $TAG does not match Configs/AppVersion.xcconfig MARKETING_VERSION $VERSION" >&2
exit 1
fi
if [[ ! "$BUILD_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Configs/AppVersion.xcconfig CURRENT_PROJECT_VERSION must be numeric" >&2
exit 1
fi
PRERELEASE="false"
if [[ "$VERSION" == *"-"* || "$VERSION" == *"beta"* || "$VERSION" == *"alpha"* || "$VERSION" == *"rc"* ]]; then
PRERELEASE="true"
fi
{
echo "TAG=$TAG"
echo "VERSION=$VERSION"
echo "BUILD_NUMBER=$BUILD_NUMBER"
echo "PRERELEASE=$PRERELEASE"
echo "DMG_PATH=${ARTIFACT_DIR}/${TAG}/${PROJECT_NAME}.dmg"
echo "SHA256_PATH=${ARTIFACT_DIR}/${TAG}/${PROJECT_NAME}.sha256"
} >> "$GITHUB_ENV"
- name: Validate required secrets
env:
APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }}
BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }}
DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
missing=0
for name in \
APPLE_DEVELOPMENT_TEAM \
BUNDLE_IDENTIFIER_PREFIX \
DEVELOPER_ID_CERT_P12 \
DEVELOPER_ID_CERT_PASSWORD \
ASC_API_KEY_P8_BASE64 \
ASC_API_KEY_ID \
ASC_API_ISSUER_ID \
SPARKLE_PRIVATE_KEY; do
if [[ -z "${!name}" ]]; then
echo "Missing required secret: $name" >&2
missing=1
fi
done
exit "$missing"
- name: Show Xcode version
run: xcodebuild -version
- name: Install XcodeGen
run: |
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
- name: Write release local config
env:
APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }}
BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }}
run: |
umask 077
cat > LocalConfig.xcconfig <<EOF
DEVELOPMENT_TEAM = ${APPLE_DEVELOPMENT_TEAM}
BUNDLE_IDENTIFIER_PREFIX = ${BUNDLE_IDENTIFIER_PREFIX}
EOF
- name: Import Developer ID certificate
env:
DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/developer-id.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 24)"
echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" \
-k "$KEYCHAIN_PATH" \
-P "$DEVELOPER_ID_CERT_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$KEYCHAIN_PASSWORD" \
"$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH"
SIGNING_IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | awk -F'"' '/Developer ID Application/ { print $2; exit }')"
if [[ -z "$SIGNING_IDENTITY" ]]; then
echo "Developer ID Application identity was not found in imported certificate." >&2
exit 1
fi
rm -f "$CERT_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV"
- name: Generate Xcode project
run: make generate
- name: Build unsigned Release app
run: |
./scripts/xcodebuild-filtered.sh \
-project "${PROJECT_NAME}.xcodeproj" \
-scheme "${SCHEME}" \
-configuration Release \
-destination "generic/platform=macOS" \
-derivedDataPath "${DERIVED_DATA}" \
MARKETING_VERSION="${VERSION}" \
CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY= \
build \
-quiet
- name: Sign app bundle
run: |
APP_PATH="${DERIVED_DATA}/Build/Products/Release/${PROJECT_NAME}.app"
ENTITLEMENTS="Configs/MacTools.entitlements"
FINDER_SYNC_ENTITLEMENTS="Sources/Extensions/RightClickFinderSync/RightClickFinderSync.entitlements"
SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
SPARKLE_CURRENT="$SPARKLE_FRAMEWORK/Versions/Current"
sign_path() {
/usr/bin/codesign \
--force \
--sign "$SIGNING_IDENTITY" \
--keychain "$KEYCHAIN_PATH" \
--options runtime \
--timestamp \
"$1"
}
sign_path_with_entitlements() {
/usr/bin/codesign \
--force \
--sign "$SIGNING_IDENTITY" \
--keychain "$KEYCHAIN_PATH" \
--options runtime \
--timestamp \
--entitlements "$2" \
"$1"
}
sign_path_preserving_entitlements() {
/usr/bin/codesign \
--force \
--sign "$SIGNING_IDENTITY" \
--keychain "$KEYCHAIN_PATH" \
--options runtime \
--timestamp \
--preserve-metadata=entitlements \
"$1"
}
if [[ -d "$SPARKLE_FRAMEWORK" ]]; then
if [[ -d "$SPARKLE_CURRENT/XPCServices/Installer.xpc" ]]; then
sign_path "$SPARKLE_CURRENT/XPCServices/Installer.xpc"
fi
if [[ -d "$SPARKLE_CURRENT/XPCServices/Downloader.xpc" ]]; then
sign_path_preserving_entitlements "$SPARKLE_CURRENT/XPCServices/Downloader.xpc"
fi
if [[ -f "$SPARKLE_CURRENT/Autoupdate" ]]; then
sign_path "$SPARKLE_CURRENT/Autoupdate"
fi
if [[ -d "$SPARKLE_CURRENT/Updater.app" ]]; then
sign_path "$SPARKLE_CURRENT/Updater.app"
fi
fi
while IFS= read -r binary; do
[[ -n "$binary" ]] || continue
sign_path "$binary"
done < <(find "$APP_PATH/Contents" -type f \( -name "*.dylib" -o -name "*.so" \) -print)
while IFS= read -r bundle; do
[[ -n "$bundle" ]] || continue
if [[ "$bundle" == *"/Sparkle.framework" || "$bundle" == *"/Sparkle.framework/"* ]]; then
continue
fi
if [[ "$(basename "$bundle")" == "RightClickFinderSync.appex" ]]; then
sign_path_with_entitlements "$bundle" "$FINDER_SYNC_ENTITLEMENTS"
else
sign_path "$bundle"
fi
done < <(find "$APP_PATH/Contents" -depth -type d \( -name "*.framework" -o -name "*.app" -o -name "*.xpc" -o -name "*.appex" \) -print)
if [[ -d "$SPARKLE_FRAMEWORK" ]]; then
sign_path "$SPARKLE_FRAMEWORK"
fi
/usr/bin/codesign \
--force \
--sign "$SIGNING_IDENTITY" \
--keychain "$KEYCHAIN_PATH" \
--options runtime \
--timestamp \
--entitlements "$ENTITLEMENTS" \
"$APP_PATH"
FINDER_SYNC_APPEX="$APP_PATH/Contents/PlugIns/RightClickFinderSync.appex"
FINDER_SYNC_EXTENSION_POINT="$(/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPointIdentifier" "$FINDER_SYNC_APPEX/Contents/Info.plist")"
if [[ "$FINDER_SYNC_EXTENSION_POINT" != "com.apple.FinderSync" ]]; then
echo "Finder Sync extension point is invalid: $FINDER_SYNC_EXTENSION_POINT" >&2
exit 1
fi
FINDER_SYNC_SIGNED_ENTITLEMENTS="$(/usr/bin/codesign -d --entitlements :- "$FINDER_SYNC_APPEX" 2>/dev/null || true)"
if [[ "$FINDER_SYNC_SIGNED_ENTITLEMENTS" != *"com.apple.security.app-sandbox"* ]]; then
echo "Finder Sync extension is missing app sandbox entitlement" >&2
exit 1
fi
if [[ "$FINDER_SYNC_SIGNED_ENTITLEMENTS" != *"com.apple.security.temporary-exception.files.home-relative-path.read-only"* ]]; then
echo "Finder Sync extension is missing right-click config read entitlement" >&2
exit 1
fi
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
- name: Create and sign DMG
env:
BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }}
run: |
APP_PATH="${DERIVED_DATA}/Build/Products/Release/${PROJECT_NAME}.app"
STAGE_DIR="$(mktemp -d "${RUNNER_TEMP}/mactools-dmg.XXXXXX")"
mkdir -p "$(dirname "$DMG_PATH")"
ditto "$APP_PATH" "$STAGE_DIR/${PROJECT_NAME}.app"
ln -s /Applications "$STAGE_DIR/Applications"
hdiutil create \
-volname "$PROJECT_NAME" \
-srcfolder "$STAGE_DIR" \
-ov \
-format UDZO \
"$DMG_PATH" >/dev/null
/bin/rm -rf "$STAGE_DIR"
/usr/bin/codesign \
--force \
--sign "$SIGNING_IDENTITY" \
--keychain "$KEYCHAIN_PATH" \
--timestamp \
--identifier "${BUNDLE_IDENTIFIER_PREFIX}.mactools.disk-image" \
"$DMG_PATH"
/usr/bin/codesign --verify --verbose=2 "$DMG_PATH"
- name: Notarize and staple DMG
env:
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
run: |
API_KEY_PATH="$RUNNER_TEMP/AuthKey_${ASC_API_KEY_ID}.p8"
echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH"
xcrun notarytool submit "$DMG_PATH" \
--key "$API_KEY_PATH" \
--key-id "$ASC_API_KEY_ID" \
--issuer "$ASC_API_ISSUER_ID" \
--wait \
--timeout 30m
xcrun stapler staple "$DMG_PATH"
spctl -a -t open --context context:primary-signature -v "$DMG_PATH"
rm -f "$API_KEY_PATH"
- name: Compute SHA256
run: |
shasum -a 256 "$DMG_PATH" | tee "$SHA256_PATH"
- name: Extract release notes from CHANGELOG
run: |
RELEASE_NOTES="$RUNNER_TEMP/release-notes.md"
HIGHLIGHTS_PATH=".github/release-highlights/${TAG}.md"
GENERATED_NOTES="$RUNNER_TEMP/release-notes.generated.md"
python3 scripts/changelog.py extract \
--tag "$TAG" \
--output "$GENERATED_NOTES"
if [[ -f "$HIGHLIGHTS_PATH" ]]; then
cat "$HIGHLIGHTS_PATH" > "$RELEASE_NOTES"
printf '\n\n' >> "$RELEASE_NOTES"
cat "$GENERATED_NOTES" >> "$RELEASE_NOTES"
else
cat "$GENERATED_NOTES" > "$RELEASE_NOTES"
fi
SPARKLE_RELEASE_NOTES="$RUNNER_TEMP/sparkle-release-notes.md"
python3 scripts/changelog.py compose-sparkle \
--tag "$TAG" \
--app-notes "$RELEASE_NOTES" \
--output "$SPARKLE_RELEASE_NOTES"
{
echo "RELEASE_NOTES_PATH=$RELEASE_NOTES"
echo "SPARKLE_RELEASE_NOTES_PATH=$SPARKLE_RELEASE_NOTES"
} >> "$GITHUB_ENV"
- name: Generate Sparkle appcast
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
SIGN_UPDATE="${DERIVED_DATA}/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update"
if [[ ! -x "$SIGN_UPDATE" ]]; then
SIGN_UPDATE="$(find "${DERIVED_DATA}/SourcePackages" -path "*/Sparkle/bin/sign_update" -type f -perm -111 | head -n 1)"
fi
if [[ -z "$SIGN_UPDATE" || ! -x "$SIGN_UPDATE" ]]; then
echo "Sparkle sign_update tool was not found." >&2
exit 1
fi
SPARKLE_KEY_PATH="$RUNNER_TEMP/sparkle_ed25519_key"
printf '%s' "$SPARKLE_PRIVATE_KEY" > "$SPARKLE_KEY_PATH"
SIGN_OUTPUT="$("$SIGN_UPDATE" "$DMG_PATH" --ed-key-file "$SPARKLE_KEY_PATH")"
rm -f "$SPARKLE_KEY_PATH"
ED_SIGNATURE="$(printf '%s' "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')"
if [[ -z "$ED_SIGNATURE" ]]; then
echo "Failed to extract Sparkle EdDSA signature." >&2
exit 1
fi
FILE_SIZE="$(stat -f '%z' "$DMG_PATH")"
DOWNLOAD_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${PROJECT_NAME}.dmg"
RELEASE_NOTES_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
FULL_RELEASE_NOTES_URL="https://github.com/${GITHUB_REPOSITORY}/releases"
PUB_DATE="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
MINIMUM_SYSTEM_VERSION="14.0"
export ED_SIGNATURE FILE_SIZE DOWNLOAD_URL RELEASE_NOTES_URL FULL_RELEASE_NOTES_URL PUB_DATE MINIMUM_SYSTEM_VERSION
python3 <<'PY_APPCAST'
import os
import xml.sax.saxutils as xml
from pathlib import Path
values = {
key: os.environ[key]
for key in [
"PROJECT_NAME",
"VERSION",
"BUILD_NUMBER",
"DOWNLOAD_URL",
"RELEASE_NOTES_URL",
"FULL_RELEASE_NOTES_URL",
"PUB_DATE",
"FILE_SIZE",
"ED_SIGNATURE",
"MINIMUM_SYSTEM_VERSION",
]
}
release_notes = Path(os.environ["SPARKLE_RELEASE_NOTES_PATH"]).read_text(encoding="utf-8")
cdata_release_notes = release_notes.replace("]]>", "]]]]><![CDATA[>")
content = f'''<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>{xml.escape(values["PROJECT_NAME"])} Releases</title>
<description>Latest release metadata for {xml.escape(values["PROJECT_NAME"])}.</description>
<language>en</language>
<item>
<title>Version {xml.escape(values["VERSION"])}</title>
<link>{xml.escape(values["RELEASE_NOTES_URL"])}</link>
<sparkle:version>{xml.escape(values["BUILD_NUMBER"])}</sparkle:version>
<sparkle:shortVersionString>{xml.escape(values["VERSION"])}</sparkle:shortVersionString>
<description sparkle:format="markdown"><![CDATA[{cdata_release_notes}]]></description>
<sparkle:fullReleaseNotesLink>{xml.escape(values["FULL_RELEASE_NOTES_URL"])}</sparkle:fullReleaseNotesLink>
<pubDate>{xml.escape(values["PUB_DATE"])}</pubDate>
<enclosure url="{xml.escape(values["DOWNLOAD_URL"])}" length="{xml.escape(values["FILE_SIZE"])}" type="application/octet-stream" sparkle:edSignature="{xml.escape(values["ED_SIGNATURE"])}" />
<sparkle:minimumSystemVersion>{xml.escape(values["MINIMUM_SYSTEM_VERSION"])}</sparkle:minimumSystemVersion>
</item>
</channel>
</rss>
'''
Path("docs/appcast.xml").write_text(content, encoding="utf-8")
PY_APPCAST
python3 -c 'import xml.etree.ElementTree as ET; ET.parse("docs/appcast.xml")'
- name: Upload notarized DMG artifact
uses: actions/upload-artifact@v4
with:
name: MacTools-${{ env.TAG }}
path: |
${{ env.DMG_PATH }}
${{ env.SHA256_PATH }}
if-no-files-found: error
retention-days: 30
- name: Create or update GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
RELEASE_ARGS=(--title "${PROJECT_NAME} ${VERSION}" --notes-file "$RELEASE_NOTES_PATH")
if [[ "$PRERELEASE" == "true" ]]; then
RELEASE_ARGS+=(--prerelease)
fi
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" "$DMG_PATH#${PROJECT_NAME}.dmg" "$SHA256_PATH#${PROJECT_NAME}.sha256" --clobber
gh release edit "$TAG" "${RELEASE_ARGS[@]}"
else
gh release create "$TAG" "$DMG_PATH#${PROJECT_NAME}.dmg" "$SHA256_PATH#${PROJECT_NAME}.sha256" "${RELEASE_ARGS[@]}"
fi
- name: Open Homebrew tap update PR
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
run: |
TAP_REPOSITORY="ggbond268/homebrew-mactools"
if [[ "$PRERELEASE" == "true" ]]; then
echo "Skipping Homebrew tap update for prerelease ${TAG}."
exit 0
fi
if [[ -z "$HOMEBREW_GITHUB_API_TOKEN" ]]; then
echo "Skipping Homebrew tap update because HOMEBREW_GITHUB_API_TOKEN is not configured."
exit 0
fi
export GH_TOKEN="$HOMEBREW_GITHUB_API_TOKEN"
SHA256="$(awk '{ print $1; exit }' "$SHA256_PATH")"
if [[ -z "$SHA256" ]]; then
echo "Unable to read SHA256 from ${SHA256_PATH}." >&2
exit 1
fi
DOWNLOAD_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${PROJECT_NAME}.dmg"
export SHA256 DOWNLOAD_URL
TAP_DIR="$(mktemp -d)"
BRANCH="update-mactools-${VERSION}"
gh repo clone "$TAP_REPOSITORY" "$TAP_DIR"
cd "$TAP_DIR"
gh auth setup-git
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
mkdir -p Casks
ruby -e 'File.write("Casks/mactools.rb", <<~CASK)
cask "mactools" do
version "#{ENV.fetch("VERSION")}"
sha256 "#{ENV.fetch("SHA256")}"
url "#{ENV.fetch("DOWNLOAD_URL")}"
name "MacTools"
desc "Menu bar toolbox"
homepage "https://github.com/#{ENV.fetch("GITHUB_REPOSITORY")}"
livecheck do
url :url
strategy :github_latest
end
auto_updates true
depends_on macos: :sonoma
app "MacTools.app"
end
CASK'
brew style Casks/mactools.rb
if git diff --quiet -- Casks/mactools.rb; then
echo "Homebrew tap cask is already up to date."
exit 0
fi
git add Casks/mactools.rb
git commit -m "Update mactools to ${VERSION}"
git push --force-with-lease origin "$BRANCH"
pr_number="$(gh pr list --repo "$TAP_REPOSITORY" --head "$BRANCH" --json number --jq '.[0].number // empty')"
if [[ -n "$pr_number" ]]; then
echo "Homebrew tap update PR already exists: #${pr_number}"
else
pr_url="$(gh pr create \
--repo "$TAP_REPOSITORY" \
--base main \
--head "$BRANCH" \
--title "Update mactools to ${VERSION}" \
--body "Updates the MacTools cask for ${TAG}.")"
pr_number="${pr_url##*/}"
echo "Created Homebrew tap update PR: #${pr_number}"
fi
gh pr merge "$pr_number" \
--repo "$TAP_REPOSITORY" \
--squash \
--delete-branch \
--subject "Update mactools to ${VERSION}"
- name: Commit appcast to main
run: |
cp docs/appcast.xml "$RUNNER_TEMP/appcast.xml"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout -B main origin/main
cp "$RUNNER_TEMP/appcast.xml" docs/appcast.xml
git add docs/appcast.xml
if git diff --cached --quiet; then
echo "No appcast changes to commit."
else
git commit -m "ci: update appcast for ${TAG}"
git push origin main
fi
- name: Cleanup keychain
if: always()
run: |
if [[ -n "${KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true
fi