| author | |
| committer | |
| log | 73b2e8fdd47807fcc0fc5bfe4928187035b71242 |
| tree | 39446840e64eb1d7f44f6039fefb12705be26bb8 |
| parent | 5da45337f7178db711624c3e7c4d69059b2b286a |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 306 insertions(+), 5 deletions(-)
.github/workflows/mobile-eas-production.yml+101-5| ... | ... | @@ -5,6 +5,23 @@ name: Mobile EAS Production |
| 5 | 5 | # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different |
| 6 | 6 | # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux |
| 7 | 7 | # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. |
| 8 | # | |
| 9 | # Every merge to main that touches the mobile app reconciles, per platform: | |
| 10 | # 1. Store builds: if the latest production build's version differs from | |
| 11 | # app.config.ts, cut a new build with --auto-submit (TestFlight + | |
| 12 | # Play internal track). Bumping `version` is therefore all it takes to | |
| 13 | # start the next release train — the first build of a version enters | |
| 14 | # external-TestFlight beta review immediately, and later builds of the | |
| 15 | # same version auto-approve. Releasing to the App Store stays a manual | |
| 16 | # App Store Connect step. | |
| 17 | # 2. OTA: publish a production-channel update for each platform where at | |
| 18 | # least one finished production build matches the current native | |
| 19 | # fingerprint. Old-version binaries with a matching fingerprint receive | |
| 20 | # it too. When native drift means no binary could install the update, | |
| 21 | # it is skipped and flagged in the job summary instead of published | |
| 22 | # into the void. | |
| 23 | # workflow_dispatch remains as a manual override for both modes (e.g. to | |
| 24 | # retry an errored build or force an OTA). | |
| 8 | 25 | on: |
| 9 | 26 | workflow_dispatch: |
| 10 | 27 | inputs: |
| ... | ... | @@ -29,10 +46,30 @@ on: |
| 29 | 46 | description: "OTA update message (mode=update only)" |
| 30 | 47 | required: false |
| 31 | 48 | type: string |
| 49 | push: | |
| 50 | branches: [main] | |
| 51 | paths: | |
| 52 | - apps/mobile/** | |
| 53 | - packages/client-runtime/** | |
| 54 | - packages/contracts/** | |
| 55 | - packages/shared/** | |
| 56 | - assets/** | |
| 57 | - scripts/** | |
| 58 | - patches/** | |
| 59 | - pnpm-lock.yaml | |
| 60 | - pnpm-workspace.yaml | |
| 61 | - .github/workflows/mobile-eas-production.yml | |
| 62 | ||
| 63 | # Serialize runs so OTAs publish in merge order. GitHub keeps at most one | |
| 64 | # queued run per group, so a burst of merges collapses into one run of the | |
| 65 | # newest commit — intermediate commits don't need their own OTA. | |
| 66 | concurrency: | |
| 67 | group: mobile-eas-production | |
| 68 | cancel-in-progress: false | |
| 32 | 69 | |
| 33 | 70 | jobs: |
| 34 | 71 | production: |
| 35 | name: EAS Production ${{ inputs.mode }} | |
| 72 | name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} | |
| 36 | 73 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 37 | 74 | permissions: |
| 38 | 75 | contents: read |
| ... | ... | @@ -98,15 +135,15 @@ jobs: |
| 98 | 135 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 99 | 136 | run: eas env:pull production --non-interactive |
| 100 | 137 | |
| 101 | - name: Build and submit | |
| 102 | if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' | |
| 138 | - name: Build and submit (manual) | |
| 139 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' | |
| 103 | 140 | working-directory: apps/mobile |
| 104 | 141 | env: |
| 105 | 142 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 106 | 143 | run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait |
| 107 | 144 | |
| 108 | - name: Publish OTA update | |
| 109 | if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' | |
| 145 | - name: Publish OTA update (manual) | |
| 146 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' | |
| 110 | 147 | working-directory: apps/mobile |
| 111 | 148 | env: |
| 112 | 149 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| ... | ... | @@ -117,3 +154,62 @@ jobs: |
| 117 | 154 | --platform ${{ inputs.platform }} \ |
| 118 | 155 | --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ |
| 119 | 156 | --non-interactive |
| 157 | ||
| 158 | # No --status filter on build:list: an in-queue/in-progress build must | |
| 159 | # count as existing, or every merge during the build window would cut a | |
| 160 | # duplicate. Builds started here stay attached to this serialized run so | |
| 161 | # the queued run for a later merge cannot overtake them and lose its OTA. | |
| 162 | # After an errored build, retry via workflow_dispatch mode=build — pushes | |
| 163 | # won't re-trigger it until the app version changes. | |
| 164 | - id: store_builds | |
| 165 | name: Ensure store builds exist for the current app version | |
| 166 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' | |
| 167 | continue-on-error: true | |
| 168 | working-directory: apps/mobile | |
| 169 | env: | |
| 170 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | |
| 171 | run: | | |
| 172 | failed=0 | |
| 173 | version="$(npx expo config --json --type public | jq -r '.version')" | |
| 174 | for platform in ios android; do | |
| 175 | latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" | |
| 176 | if [ "$latest" = "$version" ]; then | |
| 177 | echo "$platform: production build for $version already exists (or is in progress)" | |
| 178 | continue | |
| 179 | fi | |
| 180 | echo "$platform: latest production build is $latest, app.config.ts says $version — building" | |
| 181 | if eas build --platform "$platform" --profile production --auto-submit --non-interactive; then | |
| 182 | echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$GITHUB_STEP_SUMMARY" | |
| 183 | else | |
| 184 | failed=1 | |
| 185 | echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" | |
| 186 | fi | |
| 187 | done | |
| 188 | exit "$failed" | |
| 189 | ||
| 190 | - name: Publish fingerprint-gated OTA | |
| 191 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' | |
| 192 | working-directory: apps/mobile | |
| 193 | env: | |
| 194 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | |
| 195 | run: | | |
| 196 | message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" | |
| 197 | for platform in ios android; do | |
| 198 | hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | jq -r '.hash')" | |
| 199 | matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')" | |
| 200 | if [ "$matching" -gt 0 ]; then | |
| 201 | eas update \ | |
| 202 | --channel production \ | |
| 203 | --environment production \ | |
| 204 | --platform "$platform" \ | |
| 205 | --message "$message" \ | |
| 206 | --non-interactive | |
| 207 | echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" | |
| 208 | else | |
| 209 | echo ":warning: $platform: no finished production build matches fingerprint \`$hash\` — OTA skipped; JS changes reach $platform only once a matching build ships" >> "$GITHUB_STEP_SUMMARY" | |
| 210 | fi | |
| 211 | done | |
| 212 | ||
| 213 | - name: Propagate store build failure | |
| 214 | if: steps.store_builds.outcome == 'failure' | |
| 215 | run: exit 1 |
.github/workflows/mobile-fingerprint-check.yml created+205| ... | ... | @@ -0,0 +1,205 @@ |
| 1 | name: Mobile Fingerprint Check | |
| 2 | ||
| 3 | # Detects whether a PR changes the native fingerprint — i.e. whether merging | |
| 4 | # it would leave main un-OTA-able until a new store build ships. Native-change | |
| 5 | # PRs get the "📱 Native Change" label so they can be held and merged as a | |
| 6 | # batch right before the next store submission, keeping main OTA-able for | |
| 7 | # everything else in between. (Once one native PR merges, every later merge | |
| 8 | # inherits the drifted fingerprint and loses OTA reach too — that is why the | |
| 9 | # signal has to fire before merge, not after.) | |
| 10 | # | |
| 11 | # The check is advisory: it always passes, the label is the signal. Both | |
| 12 | # fingerprints are computed in this one job (same OS, same corepack-pinned | |
| 13 | # pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed. | |
| 14 | on: | |
| 15 | pull_request: | |
| 16 | paths: | |
| 17 | - apps/mobile/** | |
| 18 | - packages/client-runtime/** | |
| 19 | - packages/contracts/** | |
| 20 | - packages/shared/** | |
| 21 | - assets/** | |
| 22 | - scripts/** | |
| 23 | - patches/** | |
| 24 | - pnpm-lock.yaml | |
| 25 | - pnpm-workspace.yaml | |
| 26 | - .github/workflows/mobile-fingerprint-check.yml | |
| 27 | ||
| 28 | concurrency: | |
| 29 | group: mobile-fingerprint-check-${{ github.event.pull_request.number }} | |
| 30 | cancel-in-progress: true | |
| 31 | ||
| 32 | jobs: | |
| 33 | fingerprint: | |
| 34 | name: Native fingerprint diff | |
| 35 | runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| 36 | permissions: | |
| 37 | contents: read | |
| 38 | issues: write | |
| 39 | pull-requests: write | |
| 40 | env: | |
| 41 | APP_VARIANT: production | |
| 42 | NODE_OPTIONS: --max-old-space-size=8192 | |
| 43 | steps: | |
| 44 | - name: Checkout | |
| 45 | uses: actions/checkout@v6 | |
| 46 | with: | |
| 47 | # Default pull_request checkout is the merge commit (PR applied on | |
| 48 | # top of base), so the "head" fingerprint is the state main would | |
| 49 | # actually be in after merging — stale branches compare cleanly. | |
| 50 | fetch-depth: 0 | |
| 51 | ||
| 52 | - name: Setup Vite+ | |
| 53 | uses: voidzero-dev/setup-vp@v1 | |
| 54 | with: | |
| 55 | node-version-file: package.json | |
| 56 | cache: true | |
| 57 | run-install: | | |
| 58 | args: | |
| 59 | - --filter=@t3tools/mobile... | |
| 60 | ||
| 61 | - name: Expose pnpm | |
| 62 | run: | | |
| 63 | pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" | |
| 64 | vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" | |
| 65 | echo "$vp_pnpm_bin" >> "$GITHUB_PATH" | |
| 66 | "$vp_pnpm_bin/pnpm" --version | |
| 67 | ||
| 68 | - name: Fingerprint merge result | |
| 69 | working-directory: apps/mobile | |
| 70 | run: | | |
| 71 | mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base" | |
| 72 | for platform in ios android; do | |
| 73 | npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/head/$platform.json" | |
| 74 | done | |
| 75 | ||
| 76 | - name: Fingerprint base | |
| 77 | run: | | |
| 78 | git checkout --quiet "${{ github.event.pull_request.base.sha }}" | |
| 79 | # Re-sync node_modules to the base commit's lockfile before | |
| 80 | # fingerprinting — a dep-changing PR must not fingerprint the base | |
| 81 | # against head's installed packages. | |
| 82 | pnpm install --filter=@t3tools/mobile... | |
| 83 | cd apps/mobile | |
| 84 | for platform in ios android; do | |
| 85 | npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/base/$platform.json" | |
| 86 | done | |
| 87 | ||
| 88 | - id: compare | |
| 89 | name: Compare fingerprints | |
| 90 | run: | | |
| 91 | changed="" | |
| 92 | { | |
| 93 | echo "## Native fingerprint diff" | |
| 94 | echo | |
| 95 | for platform in ios android; do | |
| 96 | head_hash="$(jq -r .hash "$RUNNER_TEMP/fp/head/$platform.json")" | |
| 97 | base_hash="$(jq -r .hash "$RUNNER_TEMP/fp/base/$platform.json")" | |
| 98 | if [ "$head_hash" = "$base_hash" ]; then | |
| 99 | echo "- ✅ **$platform**: unchanged (\`$head_hash\`) — OTA-compatible" | |
| 100 | continue | |
| 101 | fi | |
| 102 | changed="$changed $platform" | |
| 103 | echo "- 📱 **$platform**: \`$base_hash\` → \`$head_hash\` — merging requires a new native build before OTAs work again" | |
| 104 | jq -r -n \ | |
| 105 | --slurpfile h "$RUNNER_TEMP/fp/head/$platform.json" \ | |
| 106 | --slurpfile b "$RUNNER_TEMP/fp/base/$platform.json" ' | |
| 107 | ($b[0].sources | map({ (.filePath // .id): .hash }) | add // {}) as $bm | |
| 108 | | $h[0].sources[] | |
| 109 | | select($bm[(.filePath // .id)] != .hash) | |
| 110 | | " - \(.type): `\(.filePath // .id)`"' | |
| 111 | done | |
| 112 | } >> "$GITHUB_STEP_SUMMARY" | |
| 113 | echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" | |
| 114 | ||
| 115 | - name: Sync native change label | |
| 116 | # Fork PRs get a read-only token under pull_request; the check stays | |
| 117 | # advisory there (summary only). This workflow must not move to | |
| 118 | # pull_request_target — it installs and runs PR code. | |
| 119 | if: github.event.pull_request.head.repo.full_name == github.repository | |
| 120 | uses: actions/github-script@v8 | |
| 121 | env: | |
| 122 | CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} | |
| 123 | with: | |
| 124 | script: | | |
| 125 | const managedLabel = { | |
| 126 | name: "📱 Native Change", | |
| 127 | color: "d93f0b", | |
| 128 | description: | |
| 129 | "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", | |
| 130 | }; | |
| 131 | const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; | |
| 132 | const issueNumber = context.payload.pull_request.number; | |
| 133 | ||
| 134 | try { | |
| 135 | const { data: existing } = await github.rest.issues.getLabel({ | |
| 136 | owner: context.repo.owner, | |
| 137 | repo: context.repo.repo, | |
| 138 | name: managedLabel.name, | |
| 139 | }); | |
| 140 | ||
| 141 | if ( | |
| 142 | existing.color !== managedLabel.color || | |
| 143 | (existing.description ?? "") !== managedLabel.description | |
| 144 | ) { | |
| 145 | await github.rest.issues.updateLabel({ | |
| 146 | owner: context.repo.owner, | |
| 147 | repo: context.repo.repo, | |
| 148 | name: managedLabel.name, | |
| 149 | color: managedLabel.color, | |
| 150 | description: managedLabel.description, | |
| 151 | }); | |
| 152 | } | |
| 153 | } catch (error) { | |
| 154 | if (error.status !== 404) { | |
| 155 | throw error; | |
| 156 | } | |
| 157 | ||
| 158 | try { | |
| 159 | await github.rest.issues.createLabel({ | |
| 160 | owner: context.repo.owner, | |
| 161 | repo: context.repo.repo, | |
| 162 | name: managedLabel.name, | |
| 163 | color: managedLabel.color, | |
| 164 | description: managedLabel.description, | |
| 165 | }); | |
| 166 | } catch (createError) { | |
| 167 | if (createError.status !== 422) { | |
| 168 | throw createError; | |
| 169 | } | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ | |
| 174 | owner: context.repo.owner, | |
| 175 | repo: context.repo.repo, | |
| 176 | issue_number: issueNumber, | |
| 177 | per_page: 100, | |
| 178 | }); | |
| 179 | const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); | |
| 180 | ||
| 181 | if (nativeChanged && !hasLabel) { | |
| 182 | await github.rest.issues.addLabels({ | |
| 183 | owner: context.repo.owner, | |
| 184 | repo: context.repo.repo, | |
| 185 | issue_number: issueNumber, | |
| 186 | labels: [managedLabel.name], | |
| 187 | }); | |
| 188 | } else if (!nativeChanged && hasLabel) { | |
| 189 | try { | |
| 190 | await github.rest.issues.removeLabel({ | |
| 191 | owner: context.repo.owner, | |
| 192 | repo: context.repo.repo, | |
| 193 | issue_number: issueNumber, | |
| 194 | name: managedLabel.name, | |
| 195 | }); | |
| 196 | } catch (removeError) { | |
| 197 | if (removeError.status !== 404) { | |
| 198 | throw removeError; | |
| 199 | } | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | core.info( | |
| 204 | `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, | |
| 205 | ); |