Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

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