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 < "$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