# Note: this file is ~800 lines, above the project's 400-line composite-
# action guideline. The size pre-dates the cert-caching feature; the
# signing preparation, the AI-metadata phases, and the
# export / upload chunk are candidates for extraction into separate
# scripts under ``scripts/`` (similar to how prepare_signing.py /
# cert_factory.py / keychain.py / profile_io.py split the cert +
# profile lifecycle). Refactor is a separate concern tracked outside
# this file.
# autoupdate test: 2026-05-06
name: iOS native TestFlight deploy
description: >
  Build a native Swift/SwiftUI iOS app on a macOS runner and upload it to
  TestFlight. Reuses an encrypted Apple Distribution identity when supplied,
  or provisions one through App Store Connect for legacy checkout-backed
  consumers. App Store provisioning profiles are reconciled at runtime.

inputs:
  candidate-binary:
    description: Verified candidate artifact directory supplied by the package workflow
    required: false
    default: ''
  project:
    description: "Path to the .xcodeproj (or omit if using workspace)"
    required: false
    default: ""
  workspace:
    description: "Path to the .xcworkspace (takes precedence over project)"
    required: false
    default: ""
  scheme:
    description: "Xcode scheme to archive"
    required: false
    default: ""
  configuration:
    description: "Xcode build configuration"
    required: false
    default: ""
  bundle-id:
    description: "Application bundle identifier (must already exist in ASC)"
    required: false
    default: ""
  team-id:
    description: "Apple developer team identifier (10-char)"
    required: false
    default: ""
  app-store-apple-id:
    description: "App Store Connect numeric app id (for build-number lookup)"
    required: false
    default: ""
  profile-name:
    description: >
      DEPRECATED (ignored). Profile names are now derived per-target from
      PRODUCT_BUNDLE_IDENTIFIER as `CI-<bundle_id>`. Kept for backward
      compatibility — passing a value emits a notice.
    required: false
    default: ""
  certificate-cap-policy:
    description: >
      Behavior when Apple reports the two-certificate Distribution cap.
      Only 'fail' is supported: abort without revoking any existing certificate.
      Reconcile the account registry when no live signing identity is available.
    required: false
    default: "fail"
  persist-signing-cache:
    description: >
      DEPRECATED (ignored). Signing files remain in the ephemeral checkout;
      the action never stages refreshed credentials, including legacy caches.
    required: false
    default: "false"
  upload:
    description: "Set to 'false' to stop after producing the IPA"
    required: false
    default: "true"
  archive:
    description: >
      Set to 'false' to skip archive + export + upload entirely. Useful for
      PR / branch runs that only need to build and test without code signing
      secrets. When 'false', `upload` is ignored.
    required: false
    default: "true"
  manage-app-store-version:
    description: >
      Set to 'false' to archive and upload a TestFlight build without creating
      or editing an App Store version. App Store metadata automation is skipped.
      This is useful for feature-branch validation while a release is in review.
    required: false
    default: "true"
  run-tests:
    description: "Set to 'false' to skip the simulator test stage"
    required: false
    default: ""
  test-command:
    description: >
      Shell command used for the simulator test stage. Defaults to running
      `bash Scripts/run-tests.sh` when that file exists, otherwise
      `xcodebuild test` against the chosen scheme on a booted simulator with
      code-signing disabled. Override to run a custom runner.
    required: false
    default: ""
  test-destination:
    description: >
      Destination passed to the default `xcodebuild test` invocation when
      `test-command` is empty and no `Scripts/run-tests.sh` is present.
    required: false
    default: ""
  uses-non-exempt-encryption:
    description: >
      Value to set for ITSAppUsesNonExemptEncryption in Info.plist
      (default: false). Set to 'true' only if the app uses custom crypto
      beyond Apple-standard; set to empty string to skip the write entirely.
    required: false
    default: ""
  app-store-whats-new:
    description: >
      What's New text for the App Store version (appStoreVersionLocalizations.whatsNew).
      Multi-line OK. Empty = skip. First-release versions (1.0 / 0.0) are skipped.
    required: false
    default: ""
  app-store-locale:
    description: "Locale for App Store whatsNew (default: en-US)"
    required: false
    default: ""
  ai-metadata:
    description: "Auto-fill empty ASC metadata fields via GitHub Models AI. 'false' to disable."
    required: false
    default: "true"
  ai-metadata-model:
    description: "GitHub Models model id for AI metadata generation."
    required: false
    default: "openai/gpt-4o"
  firebase-app-id:
    description: >-
      Firebase App ID(s) for the Crashlytics dSYM upload — the
      `1:<project number>:ios:<hash>` value (GOOGLE_APP_ID in
      GoogleService-Info.plist, or Project settings → Your apps in the Firebase
      console). Several ids may be listed, separated by commas or whitespace;
      the iOS one is used. Empty leaves dSYM upload to the app's own
      Crashlytics run-script phase. The package workflow passes the
      FIREBASE_APP_ID repository variable.
    required: false
    default: ""
  # DESIGN DECISION: marketing-version-auto-bump trades unattended-CI
  # smoothness against silent-semver-decisions visibility. Default
  # 'rollover' lets CI keep the build moving when ASC's combined floor
  # advances ahead of the project's MARKETING_VERSION (typical cause:
  # a manual TestFlight upload at a higher version, or a prior
  # release moving to READY_FOR_SALE between commits). 'rollover'
  # bumps the patch component with carry: at .9 it rolls into the
  # next minor (1.0.9 -> 1.1.0), and at minor=9 it cascades into the
  # next major (1.9.9 -> 2.0.0). This produces the more natural
  # human-readable progression most projects want -- patch numbers
  # never grow past 9 silently. The bumped value is written into the
  # project file and committed back via the existing bot-commit step,
  # so the change is visible in repo history.
  #
  # Other policies:
  #   'patch' -- legacy unbounded patch bump (1.0.9 -> 1.0.10).
  #     Preserved unchanged for backward compat with consumers
  #     pinning the historical default; existing 0.0.27 callers that
  #     set `marketing-version-auto-bump: 'patch'` keep their current
  #     behavior. New installs get 'rollover'.
  #   'minor' -- bump minor, reset patch to 0 (major.(max(minor)+1).0).
  #     For projects that ship every release as a minor.
  #   'none' -- preserve the historical "fail the build, ask the
  #     human to bump" behavior when explicit semver control is
  #     required (e.g. release-train workflows).
  marketing-version-auto-bump:
    description: >
      When the ASC combined floor exceeds the project's MARKETING_VERSION,
      automatically bump and stage the project file instead of failing.
      Values: 'rollover' (default — patch with carry: 1.0.9 -> 1.1.0,
      1.9.9 -> 2.0.0; major has no upper limit), 'patch' (legacy
      unbounded: 1.0.9 -> 1.0.10), 'minor' (bump minor, reset patch
      to 0), 'none' (preserve existing fail behavior; requires human
      bump). The bumped value is committed alongside cert and
      autoupdate changes via the existing commit-back step.
    required: false
    default: "rollover"
  # DESIGN DECISION: auto-update defaults to 'true' as the explicit feature.
  # Trust model documented in scripts/autoupdate_check.sh DESIGN DECISION
  # block. Consumers can pin via `with: { auto-update: 'false' }`.
  auto-update:
    description: >
      Per-run check for a newer gowalk-cicd on npm. When 'true'
      (default), the action queries npm on default-branch push runs and
      installs the package in a detached worktree via `npx --yes` if its version is
      newer than the marker at .github/actions/swift-app/.daemux-version.
      The refreshed action files (NOT deploy.yml, which GITHUB_TOKEN
      cannot push) are retained on a verified task branch for the app session's
      checked PR. Set to 'false' to pin the vendored copy.
    required: false
    default: "true"

runs:
  using: composite
  steps:
    # Keep every script and prompt on the version this invocation started with.
    # Auto-update installs and publishes from a detached worktree; it never changes
    # action manifests that the runner may reparse using already-cached step IDs.
    - name: Snapshot this action for the whole run
      shell: bash
      run: |
        set -euo pipefail
        rm -rf "$RUNNER_TEMP/swift-app-action"
        mkdir -p "$RUNNER_TEMP/swift-app-action"
        cp -R "${{ github.action_path }}/." "$RUNNER_TEMP/swift-app-action/"
        echo "SWIFT_APP_ACTION=$RUNNER_TEMP/swift-app-action" >> "$GITHUB_ENV"

    # The plugin self-update runs BEFORE the version, signing and archive
    # steps and preserves its own source candidate. It used to run after them, so a
    # deploy that was red in the version step (App Store Connect refusing a
    # new version while one is in review, GoVolt run 33743284641) never
    # reached the self-update, and the plugin release that fixed exactly
    # that failure could not arrive in the repository that needed it.
    # Preserving here rather than after signing is the same
    # point: a red step later in this run must not take the update with it.
    - name: Activate verified candidate binary
      if: ${{ inputs.candidate-binary != '' }}
      shell: bash
      env:
        CANDIDATE_DIRECTORY: ${{ inputs.candidate-binary }}
      run: |
        python3 -m pip install --quiet --break-system-packages PyJWT cryptography requests
        node "$SWIFT_APP_ACTION/scripts/mobile_artifact_inventory.cjs" activate ios "$CANDIDATE_DIRECTORY"

    - name: Stage bot-side scripts to runner temp
      if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
            && github.event_name == 'push'
            && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
      shell: bash
      run: |
        set -euo pipefail
        mkdir -p "$RUNNER_TEMP/swift-app-bot-scripts"
        cp "$SWIFT_APP_ACTION/scripts/autoupdate_check.sh" \
          "$RUNNER_TEMP/swift-app-bot-scripts/"
        cp "$SWIFT_APP_ACTION/scripts/autoupdate_stage.py" \
          "$RUNNER_TEMP/swift-app-bot-scripts/"
        cp "$SWIFT_APP_ACTION/scripts/commit_bot_changes.sh" \
          "$RUNNER_TEMP/swift-app-bot-scripts/"
        cp "$SWIFT_APP_ACTION/scripts/source_maintenance.py" \
          "$RUNNER_TEMP/swift-app-bot-scripts/"
        chmod +x "$RUNNER_TEMP/swift-app-bot-scripts/"*.sh
        echo "BOT_SCRIPTS_DIR=$RUNNER_TEMP/swift-app-bot-scripts" >> "$GITHUB_ENV"

    - name: Check for app-ci update
      # Default branch + push event only. `auto-update` is on by default;
      # consumers can pin via `false`. Runs from $BOT_SCRIPTS_DIR
      # (RUNNER_TEMP); installation and candidate preservation run in an isolated
      # checkout so current action manifests and scripts remain unchanged.
      if: ${{ inputs.auto-update == 'true'
            && github.event_name == 'push'
            && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
      shell: bash
      working-directory: ${{ github.workspace }}
      run: bash "$BOT_SCRIPTS_DIR/autoupdate_check.sh"

    - name: Commit + push refreshed app-ci
      # Preserve a source candidate immediately, even when later version/signing
      # fails. The app session lands it through its normal protected PR.
      # deploy.yml is deliberately not staged (GITHUB_TOKEN
      # cannot push workflow files). No-op when the plugin was current.
      if: ${{ inputs.auto-update == 'true'
            && github.event_name == 'push'
            && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
      shell: bash
      working-directory: ${{ github.workspace }}
      env:
        GITHUB_REF_NAME: ${{ github.ref_name }}
      run: |
        set -euo pipefail
        bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"

    - name: Select latest Xcode
      if: ${{ inputs.candidate-binary == '' }}
      shell: bash
      run: |
        XCODE_PATH=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1)
        if [ -z "$XCODE_PATH" ]; then
          XCODE_PATH=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1)
        fi
        echo "Selecting $XCODE_PATH"
        sudo xcode-select -s "$XCODE_PATH"
        xcodebuild -version

    # Two store refusals are decided entirely by files already in the checkout,
    # and both cost a full cycle to learn about: shipping the framework's
    # PLACEHOLDER icon (Apple's guideline 2.3.8, discovered only after review)
    # and an asset catalog that does not cover a device family the target
    # declares (ITMS-90023, discovered only after the archive is built, signed
    # and uploaded). Reading them before the build means the job fails in
    # under a second instead of paying for the archive first. The stock artwork
    # comes from the SDK this job already installed, so it tracks an upgrade.
    - name: Check app artwork and icon coverage
      if: ${{ inputs.archive == 'true' }}
      shell: bash
      run: |
        set -o pipefail
        python3 "$SWIFT_APP_ACTION/scripts/source_preflight.py" "$GITHUB_WORKSPACE"

    - name: Describe native dependency cache
      if: ${{ inputs.candidate-binary == '' }}
      id: native_cache_scope
      continue-on-error: true
      shell: bash
      run: |
        python3 "$SWIFT_APP_ACTION/scripts/native_cache.py"
        node "$SWIFT_APP_ACTION/scripts/cache_scope.cjs" --snapshot

    - name: Restore native dependencies
      id: native_cache
      if: ${{ steps.native_cache_scope.outputs.key != '' && inputs.candidate-binary == '' }}
      uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
      continue-on-error: true
      env:
        NODE_OPTIONS: --require "${{ steps.native_cache_scope.outputs.preloader }}"
        SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
      with:
        key: ${{ steps.native_cache_scope.outputs.key }}
        path: ${{ steps.native_cache_scope.outputs.paths }}

    - name: Resolve credentials + auto-detect Xcode project
      if: ${{ inputs.candidate-binary == '' }}
      shell: bash
      env:
        INPUT_PROJECT: ${{ inputs.project }}
        INPUT_WORKSPACE: ${{ inputs.workspace }}
        INPUT_SCHEME: ${{ inputs.scheme }}
        INPUT_CONFIGURATION: ${{ inputs.configuration }}
        INPUT_BUNDLE_ID: ${{ inputs.bundle-id }}
        INPUT_TEAM_ID: ${{ inputs.team-id }}
        INPUT_APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id }}
        INPUT_PROFILE_NAME: ${{ inputs.profile-name }}
        INPUT_USES_NON_EXEMPT: ${{ inputs.uses-non-exempt-encryption }}
        INPUT_WHATS_NEW: ${{ inputs.app-store-whats-new }}
        INPUT_LOCALE: ${{ inputs.app-store-locale }}
        INPUT_RUN_TESTS: ${{ inputs.run-tests }}
        INPUT_TEST_COMMAND: ${{ inputs.test-command }}
        INPUT_TEST_DESTINATION: ${{ inputs.test-destination }}
      run: |
        # read_config.py runs FIRST (it derives every other value), so it must
        # install its full dep set inline. All later steps reuse these.
        # No YAML loader needed — there is no config file.
        python3 -m pip install --quiet --break-system-packages PyJWT cryptography requests
        # xcodegen: when project.yml is present but .xcodeproj is not committed
        # (common xcodegen flow), auto_detect will shell out to `xcodegen
        # generate`. Install it here so the command is available. Idempotent:
        # no-op when already present (GitHub-hosted macOS runners preinstall it).
        if [ -f project.yml ] && ! command -v xcodegen >/dev/null 2>&1; then
          echo "Installing xcodegen (project.yml present, binary missing)…"
          brew install xcodegen >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install xcodegen
        fi
        if ! python3 "$SWIFT_APP_ACTION/scripts/native_pods.py" "$GITHUB_WORKSPACE" \
          --container "${INPUT_WORKSPACE:-$INPUT_PROJECT}" \
          > "$RUNNER_TEMP/native-pods.json"; then
          cat "$RUNNER_TEMP/native-pods.json"
          exit 1
        fi
        # CocoaPods adds the dependency workspace even when the app supplied only its project.
        INPUT_WORKSPACE=$(python3 - <<'PY_NATIVE_WORKSPACE'
        import json, os
        with open(os.environ["RUNNER_TEMP"] + "/native-pods.json") as receipt:
            print(json.load(receipt).get("workspace", os.environ.get("INPUT_WORKSPACE", "")))
        PY_NATIVE_WORKSPACE
        )
        export INPUT_WORKSPACE
        python3 "$SWIFT_APP_ACTION/scripts/read_config.py"

    - name: Save native dependencies
      if: >-
        ${{ steps.native_cache_scope.outputs.key != '' && steps.native_cache.outputs.cache-hit != 'true'
            && inputs.candidate-binary == '' }}
      uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
      continue-on-error: true
      env:
        NODE_OPTIONS: --require "${{ steps.native_cache_scope.outputs.preloader }}"
      with:
        key: ${{ steps.native_cache_scope.outputs.key }}
        path: ${{ steps.native_cache_scope.outputs.paths }}

    - name: Run simulator tests
      if: ${{ (inputs.run-tests || env.CFG_RUN_TESTS) == 'true' && inputs.candidate-binary == '' }}
      shell: bash
      env:
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
        SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
        CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
        TEST_COMMAND: ${{ inputs.test-command || env.CFG_TEST_COMMAND }}
        TEST_DESTINATION: >-
          ${{ inputs.test-destination || env.CFG_TEST_DESTINATION
              || 'platform=iOS Simulator,name=iPhone 15' }}
      run: |
        set -o pipefail
        python3 "$SWIFT_APP_ACTION/scripts/native_dependency_guard.py" "$GITHUB_WORKSPACE" \
          --container "${WORKSPACE:-$PROJECT}"
        if [ -n "$TEST_COMMAND" ]; then
          echo "Running custom test command: $TEST_COMMAND"
          bash -c "$TEST_COMMAND"
        elif [ -f "Scripts/run-tests.sh" ]; then
          echo "Running Scripts/run-tests.sh"
          bash Scripts/run-tests.sh
        else
          if [ -n "$WORKSPACE" ]; then
            PROJ_ARGS=(-workspace "$WORKSPACE")
          else
            PROJ_ARGS=(-project "$PROJECT")
          fi
          echo "Running xcodebuild test on $TEST_DESTINATION"
          xcodebuild \
            "${PROJ_ARGS[@]}" \
            -scheme "$SCHEME" \
            -configuration "$CONFIGURATION" \
            -destination "$TEST_DESTINATION" \
            -disableAutomaticPackageResolution -skipPackageUpdates \
            CODE_SIGNING_ALLOWED=NO \
            CODE_SIGNING_REQUIRED=NO \
            CODE_SIGN_IDENTITY="" \
            DEVELOPMENT_TEAM="" \
            test
        fi

    - name: Stage ASC API key for authenticated REST uploads
      if: ${{ inputs.archive == 'true' }}
      shell: bash
      run: |
        # SECURITY: never consume the raw key through an env var — GH Actions
        # prints env: blocks on step-group expansion, which leaks secrets.
        # read_config.py already validated creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8
        # exists and exported ASC_KEY_P8_PATH; we copy the file on disk and
        # stage it for the authenticated REST clients only.
        : "${ASC_KEY_ID:?ASC_KEY_ID env var is required}"
        : "${ASC_ISSUER_ID:?ASC_ISSUER_ID env var is required}"
        : "${ASC_KEY_P8_PATH:?ASC_KEY_P8_PATH env var is required (set by read_config.py)}"
        if [ ! -f "$ASC_KEY_P8_PATH" ]; then
          echo "::error::ASC_KEY_P8_PATH points to missing file: $ASC_KEY_P8_PATH"
          exit 1
        fi
        mkdir -p "$RUNNER_TEMP/asc"
        cp "$ASC_KEY_P8_PATH" "$RUNNER_TEMP/asc/AuthKey.p8"
        chmod 600 "$RUNNER_TEMP/asc/AuthKey.p8"
        echo "ASC_KEY_PATH=$RUNNER_TEMP/asc/AuthKey.p8" >> "$GITHUB_ENV"

    - name: Resolve marketing version (project source of truth)
      if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
      shell: bash
      env:
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
        SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
        CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
      run: |
        set -o pipefail
        python3 "$SWIFT_APP_ACTION/scripts/native_dependency_guard.py" "$GITHUB_WORKSPACE" \
          --container "${WORKSPACE:-$PROJECT}"
        python3 "$SWIFT_APP_ACTION/scripts/resolve_marketing_version.py" \
          | tee -a "$GITHUB_ENV"

    - name: Resolve App Store version slot (REUSE or CREATE)
      # A third answer, TESTFLIGHT_ONLY, is handled inside: see the
      # STORE_VERSION_LOCKED branch of the script.
      if: >-
        ${{ inputs.archive == 'true'
            && (inputs.manage-app-store-version == 'true' || env.MOBILE_CANDIDATE_BUILD == 'true') }}
      shell: bash
      env:
        APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
        # When the ASC combined floor exceeds the project's MARKETING_VERSION,
        # mmv auto-bumps the project file (pbxproj or Info.plist) per this
        # policy and continues. 'none' preserves the historical fail-the-
        # build behavior.
        MARKETING_VERSION_AUTO_BUMP: ${{ inputs.candidate-binary != '' && 'none' || inputs.marketing-version-auto-bump }}
        # Auto-bump persistence-context inputs. mmv_floor_check refuses to
        # auto-bump unless this run is push-to-default-branch -- the same
        # gate the commit-back step uses. Without this gate,
        # workflow_dispatch / pull_request / feature-branch runs would
        # auto-bump in-tree, archive an IPA at the bumped version, and
        # then drop the bump on the floor (commit-back is gated to
        # default-branch push). The next run would re-bump or drift.
        GITHUB_EVENT_NAME: ${{ github.event_name }}
        GITHUB_REF: ${{ github.ref }}
        GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
        SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
        CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
      run: |
        set -o pipefail
        echo "SOURCE_MARKETING_VERSION=${SOURCE_MARKETING_VERSION:-$MARKETING_VERSION}" >> "$GITHUB_ENV"
        BUILD_NUMBER=${REUSED_BUILD_NUMBER:-$(python3 "$SWIFT_APP_ACTION/scripts/next_build_number.py")}
        DECISION_JSON=$(python3 "$SWIFT_APP_ACTION/scripts/manage_marketing_version.py")
        APP_STORE_VERSION_ID=$(python3 -c \
          "import json,sys; d=json.loads(sys.argv[1]); print(d.get('appStoreVersionId',''))" \
          "$DECISION_JSON")
        DECISION=$(python3 -c \
          "import json,sys; d=json.loads(sys.argv[1]); print(d.get('decision',''))" \
          "$DECISION_JSON")
        # The decision's versionString is authoritative: it reflects any
        # auto-bump that mmv applied to MARKETING_VERSION when the project
        # was below the ASC combined floor — or, for TESTFLIGHT_ONLY, the
        # App Store version Apple is holding, which is the train this build
        # joins. Re-export so subsequent steps (Stamp Info.plist, Archive)
        # see that value.
        EFFECTIVE_MV=$(python3 -c \
          "import json,sys; d=json.loads(sys.argv[1]); print(d.get('versionString',''))" \
          "$DECISION_JSON")
        if [ -n "${REUSED_BUILD_NUMBER:-}" ] && [ "$EFFECTIVE_MV" != "$MARKETING_VERSION" ]; then
          echo '::error::mobile_artifact_store_version_changed'
          exit 1
        fi
        if [ -n "${REUSED_BUILD_NUMBER:-}" ] && [ "$DECISION" != "TESTFLIGHT_ONLY" ] &&
           [ "$SOURCE_MARKETING_VERSION" != "$MARKETING_VERSION" ]; then
          python3 - <<'PY_PERSIST_VERSION'
        import os, sys
        sys.path.insert(0, os.environ["SWIFT_APP_ACTION"] + "/scripts")
        from version_utils import write_marketing_version
        if not write_marketing_version(os.environ["MARKETING_VERSION"]):
            raise SystemExit("mobile_artifact_version_source_unresolved")
        PY_PERSIST_VERSION
        fi
        if [ "$DECISION" = "TESTFLIGHT_ONLY" ]; then
          # An App Store version is under review or awaiting the developer's
          # release: App Store Connect refuses to create or edit ANY version
          # (409 ENTITY_ERROR.RELATIONSHIP.INVALID). The build still ships to
          # TestFlight, stamped with the locked version so it lands in that
          # version's train; App Store metadata below is skipped
          # (STORE_VERSION_LOCKED), and the typed check-run annotation tells
          # the panel that merged this commit exactly what happened.
          LOCKED_STATE=$(python3 -c \
            "import json,sys; d=json.loads(sys.argv[1]); print(d.get('state',''))" \
            "$DECISION_JSON")
          echo "::notice::App Store version $EFFECTIVE_MV is $LOCKED_STATE; uploading build $BUILD_NUMBER to TestFlight under $EFFECTIVE_MV and skipping App Store metadata"
          echo "STORE_VERSION_LOCKED=$EFFECTIVE_MV" >> "$GITHUB_ENV"
          echo "::notice title=store_version_locked::{\"schema\":\"gowalk-cicd/store-version-locked.v1\",\"version\":\"$EFFECTIVE_MV\",\"state\":\"$LOCKED_STATE\",\"build_number\":\"$BUILD_NUMBER\"}"
          echo "MARKETING_VERSION=$EFFECTIVE_MV" >> "$GITHUB_ENV"
          MARKETING_VERSION="$EFFECTIVE_MV"
        elif [ -n "$EFFECTIVE_MV" ] && [ "$EFFECTIVE_MV" != "$MARKETING_VERSION" ]; then
          echo "::notice::auto-bump rewrote MARKETING_VERSION $MARKETING_VERSION -> $EFFECTIVE_MV"
          echo "MARKETING_VERSION=$EFFECTIVE_MV" >> "$GITHUB_ENV"
          MARKETING_VERSION="$EFFECTIVE_MV"
        fi
        echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"
        echo "APP_STORE_VERSION_ID=$APP_STORE_VERSION_ID" >> "$GITHUB_ENV"
        echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER (decision=$DECISION_JSON)"

    - name: Resolve TestFlight build number
      if: >-
        ${{ inputs.archive == 'true' && inputs.manage-app-store-version == 'false'
            && env.MOBILE_CANDIDATE_BUILD != 'true' && inputs.candidate-binary == '' }}
      shell: bash
      env:
        APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
      run: |
        set -o pipefail
        BUILD_NUMBER=$(python3 "$SWIFT_APP_ACTION/scripts/next_build_number.py")
        echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"
        echo "APP_STORE_VERSION_ID=" >> "$GITHUB_ENV"
        echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER without an App Store version slot"

    - name: Notice on deprecated profile-name input
      if: ${{ inputs.archive == 'true' && inputs.profile-name != '' }}
      shell: bash
      run: |
        echo "::notice::The 'profile-name' input is deprecated and ignored." \
             "Profile names are derived per-target as CI-<bundle_id>."

    - name: Prepare signing (cert + keychain + per-target profiles + pbxproj)
      if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
      shell: bash
      env:
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
        TEAM_ID: ${{ inputs.team-id || env.CFG_TEAM_ID }}
        CREDS_DIR: ${{ github.workspace }}/creds
        CERTIFICATE_CAP_POLICY: ${{ inputs.certificate-cap-policy }}
      run: |
        set +e
        python3 "$SWIFT_APP_ACTION/scripts/prepare_signing.py"
        signing_exit=$?
        set -e
        echo "SIGNING_PREP_EXIT=$signing_exit" >> "$GITHUB_ENV"
        if [ "$signing_exit" -ne 0 ]; then
          echo "::error::Signing preparation failed; preserving staged source before failing."
        fi

    # Signing files remain in the ephemeral checkout for archive/export.
    # Only version changes staged before prepare_signing enter source maintenance.

    - name: Commit + push staged bot changes
      # Any marketing-version auto-bump the version step staged is preserved.
      # The autoupdate has its own candidate at the top of
      # this action; this script is a no-op when nothing is staged, so a
      # PR / feature-branch run that somehow reaches this step does no
      # harm — but we still gate on default-branch push for clarity.
      # Runs from the same immutable $BOT_SCRIPTS_DIR as the update candidate.
      if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
            && github.event_name == 'push'
            && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
      shell: bash
      working-directory: ${{ github.workspace }}
      env:
        GITHUB_REF_NAME: ${{ github.ref_name }}
      run: bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"

    - name: Re-raise signing preparation failure
      if: ${{ inputs.archive == 'true' && env.SIGNING_PREP_EXIT != '0' && inputs.candidate-binary == '' }}
      shell: bash
      run: |
        echo "::error::Signing preparation failed with exit $SIGNING_PREP_EXIT."
        exit "$SIGNING_PREP_EXIT"

    - name: Stamp Info.plist versions (CFBundleVersion + CFBundleShortVersionString)
      if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
      shell: bash
      env:
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
        SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
        CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
        BUNDLE_ID: ${{ inputs.bundle-id || env.CFG_BUNDLE_ID }}
      run: |
        set -o pipefail
        python3 "$SWIFT_APP_ACTION/scripts/native_dependency_guard.py" "$GITHUB_WORKSPACE" \
          --container "${WORKSPACE:-$PROJECT}"
        # Read every target's complete settings and preserve Xcode's actual status/error.
        # The selected application's source root can differ from its containing workspace.
        INFOPLIST_PATH=$(python3 "$SWIFT_APP_ACTION/scripts/resolve_info_plist.py")
        if [ -z "$INFOPLIST_PATH" ]; then
          echo "Generated Info.plist: Archive supplies CURRENT_PROJECT_VERSION and MARKETING_VERSION"
          exit 0
        fi
        /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$INFOPLIST_PATH" 2>/dev/null \
          || /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $BUILD_NUMBER" "$INFOPLIST_PATH"
        /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$INFOPLIST_PATH" 2>/dev/null \
          || /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $MARKETING_VERSION" "$INFOPLIST_PATH"
        echo "Stamped CFBundleVersion=$BUILD_NUMBER CFBundleShortVersionString=$MARKETING_VERSION in $INFOPLIST_PATH"

    - name: Declare ITSAppUsesNonExemptEncryption in Info.plist
      if: >-
        ${{ inputs.archive == 'true' && (inputs.uses-non-exempt-encryption || env.CFG_USES_NON_EXEMPT) != ''
            && inputs.candidate-binary == '' }}
      shell: bash
      env:
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
        SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
        CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
        VALUE: ${{ inputs.uses-non-exempt-encryption || env.CFG_USES_NON_EXEMPT }}
      run: |
        set -o pipefail
        # Reuse the validated application path from version stamping; no second Xcode query.
        INFOPLIST_PATH="${SWIFT_APP_SOURCE_INFOPLIST?Source plist resolution did not complete}"
        if [ -z "$INFOPLIST_PATH" ]; then
          echo "::warning::The application generates Info.plist; no source plist to edit for encryption"
          exit 0
        fi
        if [ ! -f "$INFOPLIST_PATH" ]; then
          echo "::error::The resolved application Info.plist is no longer present"
          exit 1
        fi
        /usr/libexec/PlistBuddy -c "Set :ITSAppUsesNonExemptEncryption $VALUE" "$INFOPLIST_PATH" 2>/dev/null \
          || /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool $VALUE" "$INFOPLIST_PATH"
        echo "Set ITSAppUsesNonExemptEncryption=$VALUE in $INFOPLIST_PATH"

    - name: Defer native Firebase upload phases to the proxied console
      if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
      shell: bash
      env:
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
      run: |
        python3 "$SWIFT_APP_ACTION/scripts/prepare_crashlytics_build.py" \
          --search-root "$(dirname "${WORKSPACE:-$PROJECT}")"

    - name: Archive
      if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
      shell: bash
      env:
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
        SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
        CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
      run: |
        set -o pipefail
        python3 "$SWIFT_APP_ACTION/scripts/native_dependency_guard.py" "$GITHUB_WORKSPACE" \
          --container "${WORKSPACE:-$PROJECT}"
        if [ -n "$WORKSPACE" ]; then
          PROJ_ARGS=(-workspace "$WORKSPACE")
        else
          PROJ_ARGS=(-project "$PROJECT")
        fi
        # Manual signing. prepare_signing.py patched each signable target's
        # build settings in-place (CODE_SIGN_STYLE=Manual,
        # PROVISIONING_PROFILE_SPECIFIER=CI-<bundle_id>, etc) so xcodebuild
        # just honors what's in the pbxproj. SwiftPM / resource-bundle
        # targets are untouched and keep their default (no-sign) config.
        # Speed: the index store is IDE-only overhead, and SwiftPM plugin/macro
        # validation prompts have no place on a throwaway CI runner.
        xcodebuild \
          "${PROJ_ARGS[@]}" \
          -scheme "$SCHEME" \
          -configuration "$CONFIGURATION" \
          -destination "generic/platform=iOS" \
          -archivePath "$RUNNER_TEMP/app.xcarchive" \
          -disableAutomaticPackageResolution -skipPackageUpdates \
          -skipPackagePluginValidation \
          -skipMacroValidation \
          CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
          MARKETING_VERSION="$MARKETING_VERSION" \
          COMPILER_INDEX_STORE_ENABLE=NO \
          archive

    - name: Export IPA
      if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
      shell: bash
      run: |
        set -o pipefail
        # Manual signing: re-sign every target against its CI-<bundle_id>
        # profile. exportArchive requires a full provisioningProfiles map
        # in ExportOptions.plist for any entitlement that triggers
        # validation (Personal VPN, NetworkExtension, Widgets, etc).
        # signing_map.json also carries the ASC API key's effective team,
        # which is what the profiles were actually issued under.
        python3 - <<'PY'
        import json
        import os
        from pathlib import Path
        runner_temp = os.environ["RUNNER_TEMP"]
        data = json.loads(
            Path(runner_temp, "signing_map.json").read_text()
        )
        team_id = data["team_id"]
        mapping = data["profiles"]
        profile_entries = "".join(
            f"    <key>{bid}</key><string>{name}</string>\n"
            for bid, name in mapping.items()
        )
        plist = f"""<?xml version="1.0" encoding="UTF-8"?>
        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
        <plist version="1.0">
        <dict>
          <key>method</key><string>app-store-connect</string>
          <key>destination</key><string>export</string>
          <key>teamID</key><string>{team_id}</string>
          <key>signingStyle</key><string>manual</string>
          <key>stripSwiftSymbols</key><true/>
          <key>uploadSymbols</key><true/>
          <key>provisioningProfiles</key>
          <dict>
        {profile_entries}  </dict>
        </dict>
        </plist>
        """
        Path(runner_temp, "ExportOptions.plist").write_text(plist)
        print(plist)
        PY
        xcodebuild \
          -exportArchive \
          -archivePath "$RUNNER_TEMP/app.xcarchive" \
          -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
          -exportPath "$RUNNER_TEMP/export"
        ls -la "$RUNNER_TEMP/export"

    # Crashlytics symbolicates from the dSYMs in the archive — for a Flutter
    # app that includes App.framework.dSYM, where the Dart frames of an
    # obfuscated build come from, and the only Apple-platform path Firebase
    # documents for such builds. The run-script phase `flutterfire configure`
    # adds already uploads that dSYM during the archive; with firebase-app-id
    # set (deploy.yml passes the FIREBASE_APP_ID repository variable) every
    # dSYM in the archive is uploaded here as well, with the upload-symbols
    # tool from the FirebaseCrashlytics pod, so a missing or broken phase
    # cannot lose the exact build's symbols. The autonomous session completes
    # the upload through the proxied Firebase console from this typed artifact.
    - name: Retain dSYMs for the proxied Firebase console
      if: >-
        ${{ inputs.archive == 'true' && (inputs.upload == 'true' || env.MOBILE_CANDIDATE_BUILD == 'true')
            && inputs.candidate-binary == '' }}
      shell: bash
      env:
        FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
        PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
        WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
      run: |
        python3 "$SWIFT_APP_ACTION/scripts/prepare_crashlytics_dsyms.py" \
          --archive "$RUNNER_TEMP/app.xcarchive" \
          --search-root "$(dirname "${WORKSPACE:-$PROJECT}")" \
          --output "$RUNNER_TEMP/firebase-symbols"

    - name: Preserve the Firebase console upload artifact
      if: ${{ always() && inputs.archive == 'true' }}
      uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
      env:
        NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
      with:
        name: ios-crashlytics-symbols-${{ github.run_id }}-${{ github.run_attempt }}
        path: ${{ runner.temp }}/firebase-symbols/
        if-no-files-found: ignore

    # App Store Connect validates a binary only AFTER the upload completes and
    # answers hours later by email, so a refusal like ITMS-90023 (a device
    # family with no icons) or ITMS-90362 (an extension key in the wrong place)
    # costs a whole release cycle. Every one of those verdicts is a fact about
    # the bundle already sitting in $RUNNER_TEMP/export. Reading it here is
    # offline, reads only a handful of plists out of the archive, and turns
    # "rejected by email tomorrow" into a named build failure with the exact
    # remedy. It runs before the upload INTENT so the release ledger never
    # records an attempt for a binary Apple was always going to refuse.
    - name: Check the exported binary against Apple's delivery rules
      if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
      shell: bash
      run: |
        set -o pipefail
        python3 "$SWIFT_APP_ACTION/scripts/delivery_preflight.py" \
          "$RUNNER_TEMP/export" \
          --scratch "$RUNNER_TEMP/delivery-preflight"

    - name: Record iOS upload intent
      if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
      shell: bash
      run: node "$SWIFT_APP_ACTION/scripts/mobile_release.cjs" begin-upload ios

    - name: Upload to TestFlight
      id: upload
      if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
      shell: bash
      env:
        APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
      run: |
        IPA=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
        python3 "$SWIFT_APP_ACTION/scripts/upload_build.py" "$IPA" \
          --report "$RUNNER_TEMP/apple-build-upload.json"

    - name: Record confirmed iOS upload
      if: ${{ steps.upload.outcome == 'success' }}
      shell: bash
      run: node "$SWIFT_APP_ACTION/scripts/mobile_release.cjs" finish-upload ios

    - name: Retain Apple upload receipt
      if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' }}
      uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
      env:
        NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
      with:
        name: apple-upload-${{ github.run_id }}-${{ github.run_attempt }}
        path: ${{ runner.temp }}/apple-build-upload.json
        if-no-files-found: ignore

    # A failed REST upload keeps the exact exported IPA next to its receipt, so a
    # continuation can resume or complete that same upload identity instead of
    # rebuilding different bytes under the number the receipt names.
    - name: Retain the exported IPA for upload recovery
      if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' && steps.upload.outcome != 'success' }}
      uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
      env:
        NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
      with:
        name: apple-ipa-${{ github.run_id }}-${{ github.run_attempt }}
        path: ${{ runner.temp }}/export/*.ipa
        if-no-files-found: ignore
        retention-days: 14

    # Both App Store metadata writers are skipped while an App Store version
    # is locked (STORE_VERSION_LOCKED, the TESTFLIGHT_ONLY decision): there is
    # no editable version id to write to, and each script requires one.
    - name: Detect empty ASC metadata fields
      id: detect_metadata
      if: >-
        ${{ inputs.archive == 'true' && inputs.upload == 'true'
            && inputs.manage-app-store-version == 'true' && inputs.ai-metadata != 'false'
            && env.STORE_VERSION_LOCKED == '' }}
      shell: bash
      env:
        APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
        APP_STORE_VERSION_ID: ${{ env.APP_STORE_VERSION_ID }}
      run: |
        set -o pipefail
        python3 "$SWIFT_APP_ACTION/scripts/asc_metadata_detector.py" \
          > "$RUNNER_TEMP/asc_empty_metadata.json" || {
            echo "::warning::metadata detector failed; skipping AI step"
            echo "has_empty=false" >> "$GITHUB_OUTPUT"
            exit 0
          }
        HAS_EMPTY=$(python3 -c \
          "import json,sys; d=json.load(open(sys.argv[1])); print('true' if d.get('empty_fields') else 'false')" \
          "$RUNNER_TEMP/asc_empty_metadata.json")
        LOCALES=$(python3 -c \
          "import json,sys; d=json.load(open(sys.argv[1])); print(','.join(sorted(d.get('locales',[]))))" \
          "$RUNNER_TEMP/asc_empty_metadata.json")
        echo "has_empty=$HAS_EMPTY" >> "$GITHUB_OUTPUT"
        echo "locales=$LOCALES" >> "$GITHUB_OUTPUT"
        echo "AI metadata detector: has_empty=$HAS_EMPTY locales=[$LOCALES]"

    - name: Scan repo for AI prompt context
      if: ${{ steps.detect_metadata.outputs.has_empty == 'true' }}
      shell: bash
      env:
        BUNDLE_ID: ${{ inputs.bundle-id || env.CFG_BUNDLE_ID }}
        SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
      run: |
        python3 "$SWIFT_APP_ACTION/scripts/app_context_scanner.py" \
          > "$RUNNER_TEMP/app_context.txt"
        wc -c "$RUNNER_TEMP/app_context.txt"

    - name: Split empty_fields into Phase-1 (description) and Phase-2 (dependents)
      id: derive_phases
      if: ${{ steps.detect_metadata.outputs.has_empty == 'true' }}
      shell: bash
      run: |
        # description is authoritative context for the dependent fields, so it
        # is generated first (Phase 1) and injected into the Phase-2 prompt.
        set -euo pipefail
        python3 -m pip install --quiet --break-system-packages PyYAML
        python3 - <<'PY'
        import json, os, pathlib
        temp = pathlib.Path(os.environ['RUNNER_TEMP'])
        state = json.loads((temp / 'asc_empty_metadata.json').read_text())
        empty = state.get('empty_fields') or {}
        locales_needing_desc = sorted(loc for loc, fields in empty.items() if 'description' in fields)
        non_desc = {loc: [f for f in fields if f != 'description'] for loc, fields in empty.items()}
        non_desc = {loc: fields for loc, fields in non_desc.items() if fields}
        (temp / 'locales_needing_description.json').write_text(json.dumps(locales_needing_desc))
        (temp / 'empty_fields_excl_desc.json').write_text(json.dumps(non_desc))
        with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
            fh.write(f"needs_phase1={'true' if locales_needing_desc else 'false'}\n")
            fh.write(f"needs_phase2={'true' if non_desc else 'false'}\n")
        print(f"Phase-1 locales needing description: {locales_needing_desc}")
        print(f"Phase-2 non-description empty fields: {non_desc}")
        PY

    - name: "Phase 1: Render descriptions prompt"
      if: ${{ steps.derive_phases.outputs.needs_phase1 == 'true' }}
      shell: bash
      env:
        TEMPLATE_PATH: ${{ env.SWIFT_APP_ACTION }}/prompts/generate_descriptions.prompt.yml
      run: |
        # Pre-render via PyYAML so multi-line app_context stays a block scalar
        # and doesn't break ai-inference@v1's naive string substitution.
        set -euo pipefail
        python3 - <<'PY'
        import os, pathlib, yaml
        tmpl = yaml.safe_load(pathlib.Path(os.environ['TEMPLATE_PATH']).read_text())
        temp = pathlib.Path(os.environ['RUNNER_TEMP'])
        app_context = (temp / 'app_context.txt').read_text()
        locales_json = (temp / 'locales_needing_description.json').read_text().strip()
        for msg in tmpl.get('messages', []):
            c = msg.get('content')
            if isinstance(c, str):
                msg['content'] = (
                    c.replace('{{app_context}}', app_context)
                     .replace('{{locales_needing_description}}', locales_json)
                )
        out = temp / 'rendered_phase1.yml'
        out.write_text(yaml.safe_dump(tmpl, sort_keys=False, allow_unicode=True, default_flow_style=False))
        print(f"Phase-1 prompt rendered -> {out} ({out.stat().st_size} bytes)")
        PY

    - name: "Phase 1: Generate descriptions via AI"
      id: ai_phase1
      if: ${{ steps.derive_phases.outputs.needs_phase1 == 'true' }}
      # AI metadata is best-effort: a model API error (413 request-too-large,
      # rate limit, outage) must never fail a build whose TestFlight upload
      # already succeeded. The apply step below tolerates an empty response.
      continue-on-error: true
      uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8 (last v1)
      with:
        prompt-file: ${{ runner.temp }}/rendered_phase1.yml
        model: ${{ inputs.ai-metadata-model }}
        max-tokens: 16000

    - name: "Phase 1: Apply AI-generated descriptions"
      id: phase1_apply
      if: ${{ steps.derive_phases.outputs.needs_phase1 == 'true' }}
      shell: bash
      env:
        APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
        APP_STORE_VERSION_ID: ${{ env.APP_STORE_VERSION_ID }}
        AI_RESPONSE: ${{ steps.ai_phase1.outputs.response }}
      run: |
        # Fence-strip + validate + apply description only. On parse failure
        # emit phase1_ok=false so Phase-2 skips (architect §7: never regress
        # to empty description by running dependents without a seed).
        set -o pipefail
        RAW="$RUNNER_TEMP/ai_descriptions.raw"
        OUT="$RUNNER_TEMP/ai_descriptions.json"
        printf '%s' "$AI_RESPONSE" > "$RAW"
        echo "--- Phase-1 AI response: $(wc -c < "$RAW") bytes; first 200 chars ---"
        head -c 200 "$RAW"; echo
        echo "--- end preview ---"
        python3 - "$RAW" "$OUT" <<'PY'
        import pathlib, re, sys
        src, dst = sys.argv[1], sys.argv[2]
        text = pathlib.Path(src).read_text(encoding="utf-8").strip()
        m = re.match(r"^```(?:json|yaml)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
        if m:
            text = m.group(1).strip()
        pathlib.Path(dst).write_text(text, encoding="utf-8")
        PY
        if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$OUT"; then
          echo "::warning::Phase-1 AI output not valid JSON; skipping Phase-2 to avoid empty-description regression"
          head -c 500 "$OUT" || true
          echo
          echo "phase1_ok=false" >> "$GITHUB_OUTPUT"
          exit 0
        fi
        python3 "$SWIFT_APP_ACTION/scripts/asc_metadata_applier.py" \
          --state "$RUNNER_TEMP/asc_empty_metadata.json" \
          --response "$OUT" \
          --fields-filter description
        echo "phase1_ok=true" >> "$GITHUB_OUTPUT"

    - name: "Phase 2: Render dependent-fields prompt"
      if: ${{ steps.derive_phases.outputs.needs_phase2 == 'true'
            && (steps.derive_phases.outputs.needs_phase1 != 'true'
                || steps.phase1_apply.outputs.phase1_ok == 'true') }}
      shell: bash
      env:
        TEMPLATE_PATH: ${{ env.SWIFT_APP_ACTION }}/prompts/generate_dependent_fields.prompt.yml
      run: |
        # Merge existing_fields.{locale}.description with Phase-1 outputs to
        # produce authoritative per-locale description context, then render
        # the Phase-2 prompt with empty_fields_excl_desc.
        set -euo pipefail
        python3 - <<'PY'
        import json, os, pathlib, sys, yaml
        temp = pathlib.Path(os.environ['RUNNER_TEMP'])
        state = json.loads((temp / 'asc_empty_metadata.json').read_text())
        descriptions = {
            loc: (fields.get('description') or '')
            for loc, fields in (state.get('existing_fields') or {}).items()
        }
        ai_path = temp / 'ai_descriptions.json'
        if ai_path.exists():
            try:
                ai_data = json.loads(ai_path.read_text())
                for loc, fields in (ai_data.get('localizations') or {}).items():
                    desc = (fields or {}).get('description')
                    if desc:
                        descriptions[loc] = desc
            except json.JSONDecodeError as e:
                print(
                    f"::warning::ignoring corrupt {ai_path.name}: {e}; "
                    f"falling back to existing descriptions",
                    file=sys.stderr,
                )
        descriptions = {loc: d for loc, d in descriptions.items() if d}
        (temp / 'descriptions_by_locale.json').write_text(json.dumps(descriptions, ensure_ascii=False))
        tmpl = yaml.safe_load(pathlib.Path(os.environ['TEMPLATE_PATH']).read_text())
        app_context = (temp / 'app_context.txt').read_text()
        empty_excl = (temp / 'empty_fields_excl_desc.json').read_text().strip()
        descriptions_json = json.dumps(descriptions, ensure_ascii=False)
        for msg in tmpl.get('messages', []):
            c = msg.get('content')
            if isinstance(c, str):
                msg['content'] = (
                    c.replace('{{app_context}}', app_context)
                     .replace('{{descriptions_by_locale}}', descriptions_json)
                     .replace('{{empty_fields}}', empty_excl)
                )
        out = temp / 'rendered_phase2.yml'
        out.write_text(yaml.safe_dump(tmpl, sort_keys=False, allow_unicode=True, default_flow_style=False))
        print(f"Phase-2 prompt rendered -> {out} ({out.stat().st_size} bytes) with {len(descriptions)} descriptions")
        PY

    - name: "Phase 2: Generate dependent fields via AI"
      id: ai_phase2
      if: ${{ steps.derive_phases.outputs.needs_phase2 == 'true'
            && (steps.derive_phases.outputs.needs_phase1 != 'true'
                || steps.phase1_apply.outputs.phase1_ok == 'true') }}
      # Best-effort (see Phase 1): never fail the build on a model API error.
      continue-on-error: true
      uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8 (last v1)
      with:
        prompt-file: ${{ runner.temp }}/rendered_phase2.yml
        model: ${{ inputs.ai-metadata-model }}
        max-tokens: 16000

    - name: "Phase 2: Apply AI-generated dependent fields"
      if: ${{ steps.derive_phases.outputs.needs_phase2 == 'true'
            && (steps.derive_phases.outputs.needs_phase1 != 'true'
                || steps.phase1_apply.outputs.phase1_ok == 'true') }}
      shell: bash
      env:
        APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
        APP_STORE_VERSION_ID: ${{ env.APP_STORE_VERSION_ID }}
        AI_RESPONSE: ${{ steps.ai_phase2.outputs.response }}
      run: |
        # Fence-strip + validate + apply filtered to dependent fields only;
        # description is never overwritten in Phase-2.
        set -o pipefail
        RAW="$RUNNER_TEMP/ai_dependent.raw"
        OUT="$RUNNER_TEMP/ai_dependent.json"
        printf '%s' "$AI_RESPONSE" > "$RAW"
        echo "--- Phase-2 AI response: $(wc -c < "$RAW") bytes; first 200 chars ---"
        head -c 200 "$RAW"; echo
        echo "--- end preview ---"
        python3 - "$RAW" "$OUT" <<'PY'
        import pathlib, re, sys
        src, dst = sys.argv[1], sys.argv[2]
        text = pathlib.Path(src).read_text(encoding="utf-8").strip()
        m = re.match(r"^```(?:json|yaml)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
        if m:
            text = m.group(1).strip()
        pathlib.Path(dst).write_text(text, encoding="utf-8")
        PY
        if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$OUT"; then
          echo "::warning::Phase-2 AI output not valid JSON after fence strip; applier will fail-open"
          head -c 500 "$OUT" || true
          echo
        fi
        python3 "$SWIFT_APP_ACTION/scripts/asc_metadata_applier.py" \
          --state "$RUNNER_TEMP/asc_empty_metadata.json" \
          --response "$OUT" \
          --fields-filter name,subtitle,keywords,promotionalText,whatsNew

    # Runs unconditionally after the AI-metadata phase as a whatsNew backstop.
    # An empty "What's New" makes a version un-submittable (ASC: "This field
    # is required" on every locale). Previously this step was skipped whenever
    # metadata was empty, deferring entirely to the AI phase — so when the AI
    # call failed (e.g. a 413 on an app with 35 locales) every locale shipped
    # blank. Now it always runs: with an explicit `app-store-whats-new` input
    # it overwrites all locales; otherwise it fills ONLY still-empty locales
    # with the default (CFG_WHATS_NEW), preserving any AI-generated notes.
    - name: Set App Store "What's New"
      if: ${{ inputs.archive == 'true' && inputs.upload == 'true'
            && inputs.manage-app-store-version == 'true'
            && env.STORE_VERSION_LOCKED == ''
            && (inputs.app-store-whats-new != '' || env.CFG_WHATS_NEW != '') }}
      shell: bash
      env:
        # Pass the whatsNew path, not the content. GitHub Actions ${{ }} YAML
        # substitution can mangle multi-line strings; a file path is a
        # single-line string that survives interpolation untouched. When the
        # action caller supplies `app-store-whats-new` directly, we write it
        # to a temp file here (still inside the run: block, so no ${{ }}
        # re-interpolation of the multi-line value).
        INPUT_WHATS_NEW: ${{ inputs.app-store-whats-new }}
        APP_STORE_WHATS_NEW_FILE: ${{ env.CFG_WHATS_NEW_FILE }}
        APP_STORE_LOCALE: ${{ inputs.app-store-locale || env.CFG_LOCALE }}
      run: |
        set -o pipefail
        # No explicit caller text -> backstop mode: only fill locales whose
        # whatsNew is still blank (don't clobber AI output). An explicit
        # app-store-whats-new input forces the text onto every locale.
        WHATS_NEW_ONLY_IF_EMPTY=true
        if [ -n "$INPUT_WHATS_NEW" ]; then
          INPUT_FILE="$RUNNER_TEMP/whats_new_input.txt"
          printf '%s' "$INPUT_WHATS_NEW" > "$INPUT_FILE"
          APP_STORE_WHATS_NEW_FILE="$INPUT_FILE"
          WHATS_NEW_ONLY_IF_EMPTY=false
        fi
        export APP_STORE_WHATS_NEW_FILE WHATS_NEW_ONLY_IF_EMPTY
        python3 "$SWIFT_APP_ACTION/scripts/set_app_store_whats_new.py"
