diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml index 699cc59d..4b1745d1 100644 --- a/.github/workflows/production-release.yml +++ b/.github/workflows/production-release.yml @@ -189,32 +189,23 @@ jobs: VERSION: ${{ steps.get-version.outputs.version }} DRY_RUN: ${{ steps.dry-run.outputs.value }} run: | - # Check if tag exists locally or remotely - if git rev-parse "$VERSION" >/dev/null 2>&1; then - echo "⚠️ Tag $VERSION already exists locally" - - if [[ "$DRY_RUN" == "true" ]]; then - echo "Dry run mode - continuing anyway" - else - echo "❌ Cannot create duplicate release" - exit 1 - fi - fi - - # Check remote tags - git fetch --tags - if git rev-parse "origin/$VERSION" >/dev/null 2>&1; then - echo "⚠️ Tag $VERSION already exists remotely" - + set -euo pipefail + # A fresh checkout carries no tags, so they are fetched before either + # lookup. Both lookups name refs/tags explicitly, because a bare + # revision also resolves branches and never matches a remote tag. + git fetch --tags --quiet + + if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null \ + || [[ -n "$(git ls-remote --tags origin "refs/tags/$VERSION")" ]]; then if [[ "$DRY_RUN" == "true" ]]; then - echo "Dry run mode - continuing anyway" + echo "⚠️ Tag $VERSION already exists - continuing in dry run" else - echo "❌ Cannot create duplicate release" + echo "::error::Tag $VERSION already exists - cannot create a duplicate release" exit 1 fi + else + echo "✅ Tag $VERSION does not exist - safe to proceed" fi - - echo "✅ Tag $VERSION does not exist - safe to proceed" # =========================================================================== # Job 2: Publish to pub.dev @@ -339,7 +330,13 @@ jobs: name: 🏷️ Create GitHub Release runs-on: ubuntu-latest needs: [validate-release, publish-to-pubdev] - if: always() && needs.validate-release.outputs.is_valid == 'true' && needs.validate-release.outputs.is_dry_run != 'true' + # Tagging a version that never reached pub.dev leaves a release pointing at + # nothing, so this waits for the publish job to actually succeed. + if: | + always() && + needs.validate-release.outputs.is_valid == 'true' && + needs.validate-release.outputs.is_dry_run != 'true' && + needs.publish-to-pubdev.result == 'success' steps: - name: Checkout repository diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 4c7c71c6..cef722d4 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -122,42 +122,12 @@ jobs: echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT echo "release_branch=$RELEASE_BRANCH" >> $GITHUB_OUTPUT - - name: Update pubspec.yaml to production version - run: | - VERSION='${{ steps.compute-version.outputs.version }}' - echo "Updating pubspec.yaml to production version: $VERSION" - sed -i "s/^version: .*/version: $VERSION/" pubspec.yaml - grep "^version:" pubspec.yaml - - - name: Update plugin version constants (Android/iOS/Dart) - run: | - VERSION='${{ steps.compute-version.outputs.version }}' - echo "Updating PLUGIN_VERSION constants to: $VERSION" - - # Android - AppsFlyerConstants.kt - ANDROID_FILE="android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt" - if [ -f "$ANDROID_FILE" ]; then - sed -i "s/PLUGIN_VERSION = \".*\"/PLUGIN_VERSION = \"$VERSION\"/" "$ANDROID_FILE" - echo "✅ Android:" && grep "PLUGIN_VERSION" "$ANDROID_FILE" - fi - - # Dart - appsflyer_constants.dart - DART_FILE="lib/src/appsflyer_constants.dart" - if [ -f "$DART_FILE" ]; then - sed -i "s/PLUGIN_VERSION = \".*\"/PLUGIN_VERSION = \"$VERSION\"/" "$DART_FILE" - echo "✅ Dart:" && grep "PLUGIN_VERSION" "$DART_FILE" - fi + # Promotion only strips the -rcN suffix, so no native pins are passed. + - name: Write production version to every surface + env: + VERSION: ${{ steps.compute-version.outputs.version }} + run: bash scripts/set-version.sh --plugin-version "$VERSION" - # iOS - AppsflyerSdkPlugin.swift - IOS_FILE="ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift" - if [ -f "$IOS_FILE" ]; then - sed -i "s/kAppsFlyerPluginVersion = \".*\"/kAppsFlyerPluginVersion = \"$VERSION\"/" "$IOS_FILE" - echo "✅ iOS:" && grep "kAppsFlyerPluginVersion" "$IOS_FILE" - else - echo "::error::iOS plugin version file not found: $IOS_FILE" >&2 - exit 1 - fi - - name: Verify every version surface agrees run: bash scripts/verify-version-consistency.sh diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index 87a55d68..7d282318 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -100,7 +100,6 @@ jobs: outputs: version: ${{ steps.compute.outputs.version }} base_version: ${{ steps.compute.outputs.base_version }} - podspec_version: ${{ steps.compute.outputs.podspec_version }} is_rc: ${{ steps.compute.outputs.is_rc }} is_valid: ${{ steps.compute.outputs.is_valid }} base_branch: ${{ steps.compute.outputs.base_branch }} @@ -186,8 +185,6 @@ jobs: # Compute base version (remove -rcN), keep +build if present BASE_VERSION=$(echo "$VERSION" | sed 's/-rc[0-9]*$//') - # Podspec version must remove both +build and -rcN - PODSPEC_VERSION=$(echo "$VERSION" | sed -E 's/(\+[0-9]+)?(-rc[0-9]+)?$//') MAJOR_MINOR=$(echo "$BASE_VERSION" | grep -oE '^[0-9]+\.[0-9]+') MAJOR=$(echo "$BASE_VERSION" | grep -oE '^[0-9]+') @@ -195,7 +192,6 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT echo "base_version=$BASE_VERSION" >> $GITHUB_OUTPUT - echo "podspec_version=$PODSPEC_VERSION" >> $GITHUB_OUTPUT echo "is_rc=true" >> $GITHUB_OUTPUT echo "is_valid=true" >> $GITHUB_OUTPUT echo "base_branch=$BASE_BRANCH_INPUT" >> $GITHUB_OUTPUT @@ -275,126 +271,25 @@ jobs: git checkout -b "$REL_BRANCH" fi - - name: Update pubspec.yaml version (RC full) - run: | - VERSION='${{ needs.validate-release.outputs.version }}' - echo "Setting pubspec.yaml version to $VERSION (includes -rcN)" - sed -i.bak "s/^version: .*/version: $VERSION/" pubspec.yaml - rm pubspec.yaml.bak - grep "^version:" pubspec.yaml - # The Android SDK is not pinned directly — it resolves transitively from # af-android-plugin-bridge (compile scope in the bridge POM). Rewriting - # the bridge pin is therefore the only way to change which SDK ships, - # which is why there is no android_sdk_version input. - - name: Update Android bridge & Purchase Connector pins - run: | - set -euo pipefail - BRIDGE_VER='${{ needs.validate-release.outputs.android_bridge_version }}' - AND_PC_VER='${{ needs.validate-release.outputs.android_pc_version }}' - FILE='android/build.gradle' - - # An artifact that is absent is an error, not a skip: sed alone would - # leave the pin at its old version and still succeed. - pin_gradle_dep() { - local artifact="$1" version="$2" - if ! grep -q "com\.appsflyer:${artifact}:" "$FILE"; then - echo "::error::${FILE} has no com.appsflyer:${artifact} pin to update" - exit 1 - fi - sed -i.bak -E "s/com\.appsflyer:${artifact}:[0-9]+\.[0-9]+\.[0-9]+/com.appsflyer:${artifact}:${version}/" "$FILE" - rm "${FILE}.bak" - grep -n "com\.appsflyer:${artifact}:" "$FILE" - } - - if [[ -n "$BRIDGE_VER" ]]; then - pin_gradle_dep af-android-plugin-bridge "$BRIDGE_VER" - else - echo "android_bridge_version not provided — leaving the af-android-plugin-bridge pin unchanged" - fi - - if [[ -n "$AND_PC_VER" ]]; then - pin_gradle_dep purchase-connector "$AND_PC_VER" - else - echo "android_pc_version not provided — leaving the purchase-connector pin unchanged" - fi - - - name: Update iOS podspec version and dependencies - run: | - set -euo pipefail - PODSPEC_VERSION='${{ needs.validate-release.outputs.podspec_version }}' - IOS_VER='${{ needs.validate-release.outputs.ios_sdk_version }}' - IOS_PC_VER='${{ needs.validate-release.outputs.ios_pc_version }}' - FILE='ios/appsflyer_sdk.podspec' - - if [ ! -f "$FILE" ]; then - echo "::error::$FILE not found — cannot prepare an iOS release without it" - exit 1 - fi - - # Spacing after the comma varies between podspec entries, so it is - # matched loosely. A pod that is absent is an error, not a skip. - pin_pod_dep() { - local pod="$1" version="$2" - if ! grep -qE "ss\.ios\.dependency '${pod}'," "$FILE"; then - echo "::error::$FILE has no '${pod}' dependency to update" - exit 1 - fi - sed -i.bak -E "s/(ss\.ios\.dependency '${pod}',[[:space:]]*)'[^']*'/\1'${version}'/" "$FILE" - rm "${FILE}.bak" - } - - if ! grep -qE "^[[:space:]]*s\.version[[:space:]]*=" "$FILE"; then - echo "::error::$FILE has no s.version line to update" - exit 1 - fi - sed -i.bak -E "s/^([[:space:]]*s\.version[[:space:]]*=[[:space:]]*)'[^']*'/\1'${PODSPEC_VERSION}'/" "$FILE" - rm "${FILE}.bak" - - pin_pod_dep AppsFlyerFramework "$IOS_VER" - - # PurchaseConnector lives in an optional subspec. Defaults to IOS_VER - # (same as ios_sdk_version) unless ios_pc_version was passed — - # same semantics as Unity's rc-release.yml. - if grep -qE "ss\.ios\.dependency 'PurchaseConnector'," "$FILE"; then - pin_pod_dep PurchaseConnector "$IOS_PC_VER" - fi - - echo "Updated podspec lines:" - grep -nE "s\.version|AppsFlyerRPC|AppsFlyerFramework|PurchaseConnector" "$FILE" - - - name: Update plugin version constants (Android/iOS/Dart) + # the bridge pin is the only way to change which SDK ships, which is why + # there is no android_sdk_version input. + - name: Write version to every surface + env: + VERSION: ${{ needs.validate-release.outputs.version }} + IOS_VER: ${{ needs.validate-release.outputs.ios_sdk_version }} + IOS_PC_VER: ${{ needs.validate-release.outputs.ios_pc_version }} + BRIDGE_VER: ${{ needs.validate-release.outputs.android_bridge_version }} + AND_PC_VER: ${{ needs.validate-release.outputs.android_pc_version }} run: | set -euo pipefail - VERSION='${{ needs.validate-release.outputs.version }}' - echo "Updating PLUGIN_VERSION constants to: $VERSION" - - # All three constants are required — they are what the plugin reports - # as its version in the RPC payload. - pin_version_constant() { - local label="$1" file="$2" constant="$3" - if [ ! -f "$file" ]; then - echo "::error::$label plugin version file not found: $file" - exit 1 - fi - if ! grep -q "${constant} = \"" "$file"; then - echo "::error::$file has no ${constant} assignment to update" - exit 1 - fi - sed -i.bak "s/${constant} = \".*\"/${constant} = \"$VERSION\"/" "$file" - rm "${file}.bak" - echo "✅ $label:" && grep -n "$constant" "$file" - } - - pin_version_constant Android \ - "android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt" \ - PLUGIN_VERSION - pin_version_constant Dart \ - "lib/src/appsflyer_constants.dart" \ - PLUGIN_VERSION - pin_version_constant iOS \ - "ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift" \ - kAppsFlyerPluginVersion + args=(--plugin-version "$VERSION") + [[ -n "$IOS_VER" ]] && args+=(--ios-sdk "$IOS_VER") + [[ -n "$IOS_PC_VER" ]] && args+=(--ios-pc "$IOS_PC_VER") + [[ -n "$BRIDGE_VER" ]] && args+=(--android-bridge "$BRIDGE_VER") + [[ -n "$AND_PC_VER" ]] && args+=(--android-pc "$AND_PC_VER") + bash scripts/set-version.sh "${args[@]}" # Single source for every version reported downstream: README, commit # message, PR body, release notes and Slack. Runs after the pins are @@ -478,6 +373,9 @@ jobs: echo "README updated versions:" sed -n '/## SDK Versions/,/## ❗/p' README.md + - name: Verify every version surface agrees + run: bash scripts/verify-version-consistency.sh + - name: Commit & push changes id: push run: | diff --git a/scripts/set-version.sh b/scripts/set-version.sh new file mode 100755 index 00000000..e491de04 --- /dev/null +++ b/scripts/set-version.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Single writer for every file that carries a version. +# +# Usage: +# scripts/set-version.sh --plugin-version X.Y.Z[-rcN] [options] +# +# --plugin-version V pubspec.yaml, the three plugin constants, podspec s.version +# --ios-sdk V podspec AppsFlyerFramework and the SPM exact pin +# --ios-pc V podspec PurchaseConnector (optional subspec) +# --android-bridge V android/build.gradle af-android-plugin-bridge +# --android-pc V android/build.gradle purchase-connector +# +# Native pins are rewritten only when passed; an omitted one is left as is. +# Every rewrite asserts that its pattern matched first, because sed reports +# success when it changes nothing, which turns a stale pattern into a silent +# no-op instead of a failed release. +# +# scripts/verify-version-consistency.sh reads the same surfaces back. +set -euo pipefail + +PLUGIN_VERSION="" +IOS_SDK="" +IOS_PC="" +ANDROID_BRIDGE="" +ANDROID_PC="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --plugin-version) PLUGIN_VERSION="$2"; shift 2 ;; + --ios-sdk) IOS_SDK="$2"; shift 2 ;; + --ios-pc) IOS_PC="$2"; shift 2 ;; + --android-bridge) ANDROID_BRIDGE="$2"; shift 2 ;; + --android-pc) ANDROID_PC="$2"; shift 2 ;; + *) echo "set-version: unknown argument '$1'" >&2; exit 1 ;; + esac +done + +if [[ -z "$PLUGIN_VERSION" ]]; then + echo "set-version: --plugin-version is required" >&2 + exit 1 +fi + +# Versions are interpolated into sed replacements, where '/' and '&' carry +# meaning. Reject anything outside the character set a version can legitimately +# use rather than let it corrupt a file silently. +for pair in "plugin-version:$PLUGIN_VERSION" "ios-sdk:$IOS_SDK" "ios-pc:$IOS_PC" \ + "android-bridge:$ANDROID_BRIDGE" "android-pc:$ANDROID_PC"; do + value="${pair#*:}" + if [[ -n "$value" && ! "$value" =~ ^[0-9A-Za-z.+-]+$ ]]; then + echo "set-version: --${pair%%:*} value '$value' is not a valid version" >&2 + exit 1 + fi +done + +PODSPEC='ios/appsflyer_sdk.podspec' +SPM='ios/appsflyer_sdk/Package.swift' +GRADLE='android/build.gradle' + +# $1 label, $2 file, $3 pattern that must exist, $4 sed expression +rewrite() { + local label="$1" file="$2" check="$3" expr="$4" + if [[ ! -f "$file" ]]; then + echo "::error::$label: $file not found" + exit 1 + fi + if ! grep -qE "$check" "$file"; then + echo "::error::$label: $file has nothing matching /$check/ to update" + exit 1 + fi + sed -i.bak -E "$expr" "$file" + rm -f "$file.bak" + echo "set $label" +} + +rewrite "pubspec version" pubspec.yaml \ + '^version:[[:space:]]' \ + "s/^version:[[:space:]].*/version: ${PLUGIN_VERSION}/" + +rewrite "Dart PLUGIN_VERSION" lib/src/appsflyer_constants.dart \ + 'PLUGIN_VERSION[[:space:]]*=' \ + "s/(PLUGIN_VERSION[[:space:]]*=[[:space:]]*)\"[^\"]*\"/\1\"${PLUGIN_VERSION}\"/" + +rewrite "Android PLUGIN_VERSION" \ + android/src/main/kotlin/com/appsflyer/appsflyersdk/AppsFlyerConstants.kt \ + 'PLUGIN_VERSION[[:space:]]*=' \ + "s/(PLUGIN_VERSION[[:space:]]*=[[:space:]]*)\"[^\"]*\"/\1\"${PLUGIN_VERSION}\"/" + +rewrite "iOS kAppsFlyerPluginVersion" \ + ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.swift \ + 'kAppsFlyerPluginVersion[[:space:]]*=' \ + "s/(kAppsFlyerPluginVersion[[:space:]]*=[[:space:]]*)\"[^\"]*\"/\1\"${PLUGIN_VERSION}\"/" + +# CocoaPods rejects prerelease and build metadata, so the podspec carries the +# bare X.Y.Z while the plugin constants keep the full version. +PODSPEC_VERSION=$(echo "$PLUGIN_VERSION" | sed -E 's/(\+[0-9]+)?(-rc[0-9]+)?$//') +rewrite "podspec s.version" "$PODSPEC" \ + "^[[:space:]]*s\.version[[:space:]]*=" \ + "s/^([[:space:]]*s\.version[[:space:]]*=[[:space:]]*)'[^']*'/\1'${PODSPEC_VERSION}'/" + +if [[ -n "$IOS_SDK" ]]; then + # Spacing after the comma varies between podspec entries, so it is matched + # loosely. + rewrite "podspec AppsFlyerFramework" "$PODSPEC" \ + "ss\.ios\.dependency 'AppsFlyerFramework'," \ + "s/(ss\.ios\.dependency 'AppsFlyerFramework',[[:space:]]*)'[^']*'/\1'${IOS_SDK}'/" + + # SPM and CocoaPods deliver the same release, so the exact pin moves with the + # podspec dependency. The AppsFlyerRPC binary target is not touched here: it + # carries a checksum that cannot be derived from a version. + rewrite "SPM AppsFlyerFramework" "$SPM" \ + 'exact:[[:space:]]*"[^"]*"' \ + "s/(exact:[[:space:]]*)\"[^\"]*\"/\1\"${IOS_SDK}\"/" +fi + +# PurchaseConnector lives in an optional subspec, so its absence is a skip. +if [[ -n "$IOS_PC" ]]; then + if grep -qE "ss\.ios\.dependency 'PurchaseConnector'," "$PODSPEC"; then + rewrite "podspec PurchaseConnector" "$PODSPEC" \ + "ss\.ios\.dependency 'PurchaseConnector'," \ + "s/(ss\.ios\.dependency 'PurchaseConnector',[[:space:]]*)'[^']*'/\1'${IOS_PC}'/" + else + echo "skip podspec PurchaseConnector — subspec not present" + fi +fi + +if [[ -n "$ANDROID_BRIDGE" ]]; then + rewrite "gradle af-android-plugin-bridge" "$GRADLE" \ + 'com\.appsflyer:af-android-plugin-bridge:' \ + "s/com\.appsflyer:af-android-plugin-bridge:[0-9]+\.[0-9]+\.[0-9]+/com.appsflyer:af-android-plugin-bridge:${ANDROID_BRIDGE}/" +fi + +if [[ -n "$ANDROID_PC" ]]; then + rewrite "gradle purchase-connector" "$GRADLE" \ + 'com\.appsflyer:purchase-connector:' \ + "s/com\.appsflyer:purchase-connector:[0-9]+\.[0-9]+\.[0-9]+/com.appsflyer:purchase-connector:${ANDROID_PC}/" +fi