This commit is contained in:
@@ -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
|
||||
@@ -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 }}."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}."
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user