From 00beaf295d03a4b568643192dd66d16fd85d386b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 14 Apr 2026 13:48:04 +0800 Subject: [PATCH] fix: repair cross-platform release CI gating --- .github/workflows/build-and-release.yml | 25 ++++-- scripts/ci/apple_signing.sh | 84 ++++++++++++++++++ scripts/ci/build_matrix_artifacts.sh | 13 ++- scripts/ci/build_version.py | 108 ++++++++++++++++++++++++ scripts/ci/compute_release_metadata.sh | 15 ++-- scripts/ci/platform_preflight.sh | 95 +++++++++++++++++++++ scripts/ci/setup_platform_deps.sh | 10 +-- scripts/package-flutter-mac-app.sh | 28 +++--- scripts/package-ios-ipa.sh | 51 ++++------- scripts/package-linux-deb.sh | 15 ++-- scripts/package-linux-rpm.sh | 14 +-- scripts/package-linux.sh | 4 +- scripts/package-windows-msi.ps1 | 17 ++-- 13 files changed, 386 insertions(+), 93 deletions(-) create mode 100644 scripts/ci/apple_signing.sh create mode 100644 scripts/ci/build_version.py create mode 100644 scripts/ci/platform_preflight.sh diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 06608d3f..23dff6e9 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -164,21 +164,36 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Set up Java 17 for Android + if: ${{ matrix.platform == 'android' }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Preflight platform lane + id: preflight + shell: bash + run: bash ./scripts/ci/platform_preflight.sh "$PLATFORM" "$SHOULD_RELEASE" + + - name: Install platform dependencies + if: ${{ steps.preflight.outputs.should_build_platform == 'true' }} + shell: bash + run: bash ./scripts/ci/setup_platform_deps.sh "$PLATFORM" + - name: Install Go - if: ${{ matrix.platform == 'macos' }} + if: ${{ matrix.platform == 'macos' && steps.preflight.outputs.should_build_platform == 'true' }} uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff with: go-version: "1.24.1" - - name: Install platform dependencies - shell: bash - run: bash ./scripts/ci/setup_platform_deps.sh "$PLATFORM" - - name: Build platform artifacts + if: ${{ steps.preflight.outputs.should_build_platform == 'true' }} shell: bash run: bash ./scripts/ci/build_matrix_artifacts.sh "$PLATFORM" "$ARCH" "$SHOULD_RELEASE" - name: Upload build artifacts + if: ${{ steps.preflight.outputs.should_build_platform == 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.artifact_name }} diff --git a/scripts/ci/apple_signing.sh b/scripts/ci/apple_signing.sh new file mode 100644 index 00000000..00808a98 --- /dev/null +++ b/scripts/ci/apple_signing.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +apple_decode_base64() { + if base64 --help 2>&1 | grep -q -- '--decode'; then + base64 --decode + else + base64 -D + fi +} + +apple_require_signing_vars() { + local missing=() + local var_name="" + + for var_name in "$@"; do + if [[ -z "${!var_name:-}" ]]; then + missing+=("$var_name") + fi + done + + if [[ "${#missing[@]}" -gt 0 ]]; then + echo "Missing Apple signing secrets: ${missing[*]}" >&2 + return 1 + fi +} + +apple_register_cleanup() { + local command="$1" + APPLE_SIGNING_CLEANUP_COMMANDS+=("$command") +} + +apple_run_cleanup() { + local status=$? + local index=0 + + for (( index=${#APPLE_SIGNING_CLEANUP_COMMANDS[@]}-1; index>=0; index-- )); do + eval "${APPLE_SIGNING_CLEANUP_COMMANDS[index]}" >/dev/null 2>&1 || true + done + + return "$status" +} + +apple_setup_signing_keychain() { + apple_require_signing_vars \ + APPLE_CERT_P12_BASE64 \ + APPLE_CERT_PASSWORD \ + APPLE_KEYCHAIN_PASSWORD + + local tmp_dir + tmp_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/xworkmate-apple.XXXXXX")" + local keychain_name="xworkmate-build.keychain-db" + local keychain_path="$HOME/Library/Keychains/$keychain_name" + local cert_path="$tmp_dir/dist-cert.p12" + + printf '%s' "$APPLE_CERT_P12_BASE64" | apple_decode_base64 > "$cert_path" + + security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$keychain_name" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path" + security import "$cert_path" -P "$APPLE_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" + security list-keychains -d user -s "$keychain_path" + security set-key-partition-list -S apple-tool:,apple: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path" + + export APPLE_SIGNING_TMP_DIR="$tmp_dir" + export APPLE_SIGNING_KEYCHAIN_PATH="$keychain_path" + + apple_register_cleanup "security delete-keychain \"$keychain_path\"" + apple_register_cleanup "rm -rf \"$tmp_dir\"" +} + +apple_install_provision_profile() { + local profile_name="${1:-xworkmate.mobileprovision}" + + apple_require_signing_vars APPLE_PROVISION_PROFILE_BASE64 + + local profile_dir="$HOME/Library/MobileDevice/Provisioning Profiles" + local profile_path="$profile_dir/$profile_name" + + mkdir -p "$profile_dir" + printf '%s' "$APPLE_PROVISION_PROFILE_BASE64" | apple_decode_base64 > "$profile_path" + + export APPLE_SIGNING_PROFILE_PATH="$profile_path" + apple_register_cleanup "rm -f \"$profile_path\"" +} diff --git a/scripts/ci/build_matrix_artifacts.sh b/scripts/ci/build_matrix_artifacts.sh index c2dfac65..6826f7f8 100755 --- a/scripts/ci/build_matrix_artifacts.sh +++ b/scripts/ci/build_matrix_artifacts.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +eval "$(python3 "$repo_root/scripts/ci/build_version.py" --format shell)" platform="${1:?platform is required}" arch="${2:?arch is required}" should_release="${3:-false}" @@ -17,7 +20,9 @@ case "$platform" in find dist -maxdepth 1 -name '*.dmg' -exec mv {} dist/macos/ \; ;; windows) - flutter build windows --release + flutter build windows --release \ + --build-name="$PLATFORM_RELEASE_VERSION" \ + --build-number="$BUILD_NUMBER" pwsh -File ./scripts/package-windows-msi.ps1 -Arch "$arch" ;; ios) @@ -25,7 +30,11 @@ case "$platform" in bash ./scripts/package-ios-ipa.sh else echo "Release secrets not required for non-release runs; building unsigned iOS app bundle." - flutter build ios --release --no-codesign + flutter build ios --release --no-codesign \ + --build-name="$PLATFORM_RELEASE_VERSION" \ + --build-number="$BUILD_NUMBER" \ + --dart-define="XWORKMATE_DISPLAY_VERSION=$DISPLAY_VERSION" \ + --dart-define="XWORKMATE_BUILD_NUMBER=$BUILD_NUMBER" mkdir -p dist/ios ( cd build/ios/iphoneos diff --git a/scripts/ci/build_version.py b/scripts/ci/build_version.py new file mode 100644 index 00000000..0e8c6138 --- /dev/null +++ b/scripts/ci/build_version.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Resolve platform-safe build versions from pubspec.yaml. + +This repo keeps prerelease semantics in the pubspec version, for example: + 1.0.0-beta.2+4 + +Packaging metadata across Apple/Linux/Windows must use the numeric release +portion only, while display-facing surfaces can keep the prerelease label. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shlex +import sys +from pathlib import Path + + +VERSION_PATTERN = re.compile(r"^version:\s*([^\n]+)", re.MULTILINE) +RELEASE_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") + + +def parse_pubspec_version(pubspec_path: Path) -> dict[str, str]: + text = pubspec_path.read_text(encoding="utf-8") + match = VERSION_PATTERN.search(text) + if match is None: + raise ValueError(f"Unable to find version in {pubspec_path}") + + raw_version = match.group(1).strip() + if not raw_version: + raise ValueError(f"Version in {pubspec_path} is empty") + + if "+" in raw_version: + display_version, build_number = raw_version.split("+", 1) + else: + display_version, build_number = raw_version, "1" + + display_version = display_version.strip() + build_number = build_number.strip() + platform_release_version = display_version.split("-", 1)[0].strip() + + if not RELEASE_PATTERN.fullmatch(platform_release_version): + raise ValueError( + "Expected pubspec version to expose a three-part numeric release " + f"prefix before prerelease/build metadata, got: {raw_version}" + ) + + if not build_number.isdigit(): + raise ValueError(f"Expected numeric build number in pubspec version, got: {raw_version}") + + return { + "raw_version": raw_version, + "display_version": display_version, + "platform_release_version": platform_release_version, + "build_number": build_number, + } + + +def emit_shell(values: dict[str, str]) -> None: + for key, value in values.items(): + env_key = key.upper() + print(f"{env_key}={shlex.quote(value)}") + + +def emit_json(values: dict[str, str]) -> None: + print(json.dumps(values)) + + +def emit_github_output(values: dict[str, str]) -> None: + for key, value in values.items(): + print(f"{key}={value}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--pubspec", + default="pubspec.yaml", + help="Path to pubspec.yaml", + ) + parser.add_argument( + "--format", + choices=("shell", "json", "github-output"), + default="json", + help="Output format", + ) + args = parser.parse_args() + + values = parse_pubspec_version(Path(args.pubspec)) + + if args.format == "shell": + emit_shell(values) + elif args.format == "github-output": + emit_github_output(values) + else: + emit_json(values) + + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: # pragma: no cover - CLI failure path + print(str(exc), file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/ci/compute_release_metadata.sh b/scripts/ci/compute_release_metadata.sh index e01e28e1..0c44dbc6 100755 --- a/scripts/ci/compute_release_metadata.sh +++ b/scripts/ci/compute_release_metadata.sh @@ -6,22 +6,25 @@ if [[ -z "${GITHUB_OUTPUT:-}" ]]; then exit 1 fi +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +eval "$(python3 "$repo_root/scripts/ci/build_version.py" --format shell)" + if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then release_tag="${GITHUB_REF_NAME}" release_title="Release ${GITHUB_REF_NAME}" - release_notes="Automated release for ${GITHUB_REF_NAME}" + release_notes="Automated release for ${DISPLAY_VERSION}" elif [[ "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" ]]; then release_tag="manual-${GITHUB_RUN_NUMBER:-0}" - release_title="Manual Build ${GITHUB_RUN_NUMBER:-0}" - release_notes="Automated manual build from ${GITHUB_SHA:-unknown}" + release_title="Manual Build ${GITHUB_RUN_NUMBER:-0} (${DISPLAY_VERSION})" + release_notes="Automated manual build ${DISPLAY_VERSION} from ${GITHUB_SHA:-unknown}" elif [[ "${GITHUB_REF:-}" == "refs/heads/main" ]]; then release_tag="latest" release_title="Latest" - release_notes="Automated latest main build from ${GITHUB_SHA:-unknown}" + release_notes="Automated latest main build ${DISPLAY_VERSION} from ${GITHUB_SHA:-unknown}" else release_tag="main-${GITHUB_RUN_NUMBER:-0}" - release_title="Main Build ${GITHUB_RUN_NUMBER:-0}" - release_notes="Automated build from ${GITHUB_SHA:-unknown}" + release_title="Main Build ${GITHUB_RUN_NUMBER:-0} (${DISPLAY_VERSION})" + release_notes="Automated build ${DISPLAY_VERSION} from ${GITHUB_SHA:-unknown}" fi { diff --git a/scripts/ci/platform_preflight.sh b/scripts/ci/platform_preflight.sh new file mode 100644 index 00000000..ff78dc27 --- /dev/null +++ b/scripts/ci/platform_preflight.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +platform="${1:?platform is required}" +should_release="${2:-false}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +emit_output() { + local key="$1" + local value="$2" + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" + else + printf '%s=%s\n' "$key" "$value" + fi +} + +set_build_state() { + local should_build="$1" + local reason="$2" + + emit_output "should_build_platform" "$should_build" + emit_output "skip_reason" "$reason" + + if [[ "$should_build" == "true" ]]; then + echo "Preflight passed for $platform." + else + echo "Skipping $platform lane: $reason" + fi +} + +case "$platform" in + linux) + set_build_state "true" "" + ;; + windows) + set_build_state "true" "" + ;; + macos) + required_vars=( + APPLE_CERT_P12_BASE64 + APPLE_CERT_PASSWORD + APPLE_KEYCHAIN_PASSWORD + ) + + missing=() + for var_name in "${required_vars[@]}"; do + if [[ -z "${!var_name:-}" ]]; then + missing+=("$var_name") + fi + done + + if [[ "${#missing[@]}" -gt 0 ]]; then + set_build_state "false" "missing macOS signing secrets: ${missing[*]}" + exit 0 + fi + + set_build_state "true" "" + ;; + ios) + if [[ "$should_release" != "true" ]]; then + set_build_state "true" "" + exit 0 + fi + + required_vars=( + APPLE_CERT_P12_BASE64 + APPLE_CERT_PASSWORD + APPLE_PROVISION_PROFILE_BASE64 + APPLE_KEYCHAIN_PASSWORD + ) + + missing=() + for var_name in "${required_vars[@]}"; do + if [[ -z "${!var_name:-}" ]]; then + missing+=("$var_name") + fi + done + + if [[ "${#missing[@]}" -gt 0 ]]; then + set_build_state "false" "missing iOS signing secrets: ${missing[*]}" + exit 0 + fi + + set_build_state "true" "" + ;; + android) + set_build_state "true" "" + ;; + *) + echo "Unsupported platform: $platform" >&2 + exit 1 + ;; +esac diff --git a/scripts/ci/setup_platform_deps.sh b/scripts/ci/setup_platform_deps.sh index 86e77ed2..be9c052c 100755 --- a/scripts/ci/setup_platform_deps.sh +++ b/scripts/ci/setup_platform_deps.sh @@ -2,6 +2,8 @@ set -euo pipefail platform="${1:?platform is required}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" case "$platform" in linux) @@ -52,16 +54,14 @@ case "$platform" in flutter_bin="$(command -v flutter)" flutter_root="$(cd "$(dirname "$flutter_bin")/.." && pwd)" - app_version="$(sed -n 's/^version:[[:space:]]*//p' pubspec.yaml | head -n 1)" - app_version="${app_version%%+*}" - version_code="${GITHUB_RUN_NUMBER:-1}" + eval "$(python3 "$repo_root/scripts/ci/build_version.py" --format shell)" cat > android/local.properties <&2 - exit 1 -fi +eval "$(python3 "$ROOT_DIR/scripts/ci/build_version.py" --format shell)" BUILD_DATE_LINE="$(sed -n 's/^build-date:[[:space:]]*//p' "$PUBSPEC_PATH" | head -n 1)" BUILD_ID_LINE="$(sed -n 's/^build-id:[[:space:]]*//p' "$PUBSPEC_PATH" | head -n 1)" -APP_VERSION="${VERSION_LINE%%+*}" -APP_BUILD="${VERSION_LINE#*+}" -if [[ "$APP_BUILD" == "$VERSION_LINE" ]]; then - APP_BUILD="1" -fi +APP_VERSION="$DISPLAY_VERSION" +APP_RELEASE_VERSION="$PLATFORM_RELEASE_VERSION" +APP_BUILD="$BUILD_NUMBER" APP_BUILD_DATE="${BUILD_DATE_LINE:-unknown}" APP_BUILD_COMMIT="${BUILD_ID_LINE:-unknown}" @@ -74,10 +71,19 @@ remove_tree_with_retries "$FLUTTER_BUILD_STATE_DIR" remove_tree_with_retries "$MACOS_BUILD_DIR" remove_tree_with_retries "$NATIVE_ASSETS_DIR" +if [[ -n "${APPLE_CERT_P12_BASE64:-}" && + -n "${APPLE_CERT_PASSWORD:-}" && + -n "${APPLE_KEYCHAIN_PASSWORD:-}" ]]; then + echo "Provisioning Apple signing certificate for macOS build..." + apple_setup_signing_keychain +else + echo "Apple signing secrets not set; using existing local macOS signing context." +fi + BUILD_ARGS=( flutter build macos "--$BUILD_MODE" - --build-name="$APP_VERSION" + --build-name="$APP_RELEASE_VERSION" --build-number="$APP_BUILD" --dart-define="XWORKMATE_DISPLAY_VERSION=$APP_VERSION" --dart-define="XWORKMATE_BUILD_NUMBER=$APP_BUILD" diff --git a/scripts/package-ios-ipa.sh b/scripts/package-ios-ipa.sh index e15ac761..6d6d9d29 100755 --- a/scripts/package-ios-ipa.sh +++ b/scripts/package-ios-ipa.sh @@ -4,17 +4,13 @@ set -euo pipefail root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" dist_dir="$root_dir/dist/ios" export_method="${APPLE_EXPORT_METHOD:-ad-hoc}" +app_store_define="${APP_STORE_DEFINE:---dart-define=XWORKMATE_APP_STORE=${XWORKMATE_APP_STORE:-true}}" +source "$root_dir/scripts/ci/apple_signing.sh" +APPLE_SIGNING_CLEANUP_COMMANDS=() +trap apple_run_cleanup EXIT mkdir -p "$dist_dir" -decode_base64() { - if base64 --help 2>&1 | grep -q -- '--decode'; then - base64 --decode - else - base64 -D - fi -} - required_vars=( APPLE_CERT_P12_BASE64 APPLE_CERT_PASSWORD @@ -34,36 +30,25 @@ if [[ "${#missing[@]}" -gt 0 ]]; then exit 1 fi -tmp_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/xworkmate-ios.XXXXXX")" -keychain_name="xworkmate-build.keychain-db" -keychain_path="$HOME/Library/Keychains/$keychain_name" -cert_path="$tmp_dir/dist-cert.p12" -profile_path="$tmp_dir/profile.mobileprovision" +eval "$(python3 "$root_dir/scripts/ci/build_version.py" --format shell)" +app_version="$DISPLAY_VERSION" +app_build="$BUILD_NUMBER" +apple_setup_signing_keychain +apple_install_provision_profile "xworkmate.mobileprovision" + +tmp_dir="$APPLE_SIGNING_TMP_DIR" export_options_path="$tmp_dir/ExportOptions.plist" -cleanup() { - security delete-keychain "$keychain_path" >/dev/null 2>&1 || true - rm -rf "$tmp_dir" -} -trap cleanup EXIT - -printf '%s' "$APPLE_CERT_P12_BASE64" | decode_base64 > "$cert_path" -printf '%s' "$APPLE_PROVISION_PROFILE_BASE64" | decode_base64 > "$profile_path" - -security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$keychain_name" -security set-keychain-settings -lut 21600 "$keychain_path" -security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path" -security import "$cert_path" -P "$APPLE_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" -security list-keychains -d user -s "$keychain_path" -security set-key-partition-list -S apple-tool:,apple: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path" - -mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" -cp "$profile_path" "$HOME/Library/MobileDevice/Provisioning Profiles/xworkmate.mobileprovision" - sed "s|\${EXPORT_METHOD}|$export_method|g" "$root_dir/ios/ExportOptions.plist" > "$export_options_path" flutter pub get -flutter build ipa --release --export-options-plist="$export_options_path" +flutter build ipa --release \ + --build-name="$PLATFORM_RELEASE_VERSION" \ + --build-number="$app_build" \ + --dart-define="XWORKMATE_DISPLAY_VERSION=$app_version" \ + --dart-define="XWORKMATE_BUILD_NUMBER=$app_build" \ + "$app_store_define" \ + --export-options-plist="$export_options_path" archive_path="$root_dir/build/ios/archive/Runner.xcarchive" if [[ -d "$archive_path" ]]; then diff --git a/scripts/package-linux-deb.sh b/scripts/package-linux-deb.sh index 5efeb5ee..fb476aa6 100644 --- a/scripts/package-linux-deb.sh +++ b/scripts/package-linux-deb.sh @@ -3,14 +3,9 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" app_name="xworkmate" -version="$(python3 - <<'PY' -from pathlib import Path -import re -text = Path("pubspec.yaml").read_text() -match = re.search(r"^version:\s*([^\n+]+)", text, re.M) -print(match.group(1) if match else "0.0.0") -PY -)" + +eval "$(python3 "$repo_root/scripts/ci/build_version.py" --format shell)" +package_version="${PLATFORM_RELEASE_VERSION}-${BUILD_NUMBER}" build_dir="$repo_root/build/linux/x64/release/bundle" stage_dir="$repo_root/build/linux/deb-stage" @@ -39,7 +34,7 @@ chmod 0755 "$stage_dir/DEBIAN/postinst" "$stage_dir/DEBIAN/postrm" cat > "$stage_dir/DEBIAN/control" < "$spec_file" <