419 lines
17 KiB
YAML
419 lines
17 KiB
YAML
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
|