diff --git a/apps/expo-go/.eslintignore b/apps/expo-go/.eslintignore deleted file mode 100644 index 22793f047af31b..00000000000000 --- a/apps/expo-go/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -/src/__generated__/ -/src/graphql/types.ts -**/*.generated.ts -/.expo -/android -/ios -/modules diff --git a/apps/expo-go/.eslintrc.js b/apps/expo-go/.eslintrc.js deleted file mode 100644 index b070daa324fd45..00000000000000 --- a/apps/expo-go/.eslintrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('expo-module-scripts/eslintrc.base.js'); diff --git a/apps/expo-go/README.md b/apps/expo-go/README.md index a298755d437741..3c159b78ed6fbd 100644 --- a/apps/expo-go/README.md +++ b/apps/expo-go/README.md @@ -36,7 +36,6 @@ If you need to make native code changes to your Expo project, such as adding cus - Run `yarn setup:native` in the root directory. - Run `yarn build` in the `packages/expo` directory. - ## Building Expo Go 1. Set up React Native @@ -45,20 +44,16 @@ Go to the `react-native-lab/react-native` directory and run `yarn install` to in You can build the React Native Android dep using `./gradlew :packages:react-native:ReactAndroid:buildCMakeDebug` in `react-native-lab/react-native` directory. This is optional because React Native will be built anyway when you build Expo Go, but can help to narrow down a potential issue surface area. -2. Run `yarn start` in `apps/expo-go` directory to start Metro - -Metro needs to run prior running the build. Verify it runs on port 80. This is because `et android-generate-dynamic-macros` / `et ios-generate-dynamic-macros` is run during the build and needs Metro on port 80 to be running. - -3. Build Expo Go +2. Build Expo Go For Android, run `./gradlew app:assembleDebug` in the `apps/expo-go/android` directory. For iOS: + - run `pod install` in the `apps/expo-go/ios` directory -- set `DEV_KERNEL_SOURCE` to `LOCAL` in `EXBuildConstants.plist` - open and run `ios/Exponent.xcworkspace` in Xcode. -4. Run Metro for Native Component List +3. Run Metro for Native Component List - `cd apps/native-component-list` - `EXPO_SDK_VERSION=UNVERSIONED npx expo start --clear` @@ -67,17 +62,6 @@ Use the Expo Go app that you built in the previous step to scan the QR code and ## Troubleshooting -- If you see -``` -error: ReferenceError: SHA-1 for file /Users/vojta/_dev/expo/react-native-lab/react-native/packages/polyfills/console.js (/Users/vojta/_dev/expo/react-native-lab/react-native/packages/polyfills/console.js) is not computed. - Potential causes: - 1) You have symlinks in your project - watchman does not follow symlinks. - 2) Check `blockList` in your metro.config.js and make sure it isn't excluding the file path. -``` - -run `rm -rf ./react-native-lab/react-native/node_modules` - - If you're seeing C++ related errors, run `find . -name ".cxx" -type d -prune -exec rm -rf '{}' +` which clears `.cxx` build artifacts. Alternatively, use the "nuke" approach below. -- If you get `A valid Firebase Project ID is required to communicate with Firebase server APIs.`, make sure you Metro is running in the `apps/expo-go` directory and run `et android-generate-dynamic-macros`. - You might need clean the project before building it. Run `./gradlew clean` in the `apps/expo-go/android` directory. -- the "nuke" option is `git submodule foreach --recursive git clean -xfd` and / or `git clean -xfd` which removes all untracked files so you need to run the setup script `./scripts/download-dependencies.sh` again and building then takes a bit longer - but this approach appears to be effective. +- The "nuke" option is `git submodule foreach --recursive git clean -xfd` and / or `git clean -xfd` which removes all untracked files so you need to run the setup script `./scripts/download-dependencies.sh` again and building then takes a bit longer - but this approach appears to be effective. diff --git a/apps/expo-go/android/app/build.gradle b/apps/expo-go/android/app/build.gradle index 6973270f9e2fbf..731492a7a50f7e 100644 --- a/apps/expo-go/android/app/build.gradle +++ b/apps/expo-go/android/app/build.gradle @@ -24,12 +24,6 @@ buildscript { apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'com.google.firebase.crashlytics' - -def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") -apply from: expoModulesCorePlugin -applyKotlinExpoModulesCorePlugin() -useDefaultAndroidSdkVersions() - apply plugin: 'com.facebook.react' react { @@ -49,6 +43,11 @@ react { } android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + namespace "host.exp.exponent" buildFeatures { @@ -57,6 +56,8 @@ android { defaultConfig { applicationId 'host.exp.exponent' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion versionCode 229 versionName '55.0.2' diff --git a/apps/expo-go/android/expoview/build.gradle b/apps/expo-go/android/expoview/build.gradle index 5095b4baf6358f..bb5f90fc7bdd57 100644 --- a/apps/expo-go/android/expoview/build.gradle +++ b/apps/expo-go/android/expoview/build.gradle @@ -21,11 +21,15 @@ plugins { alias libs.plugins.download id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" // Use your Kotlin version + id 'expo-module-gradle-plugin' } apply plugin: 'kotlin-kapt' apply from: new File(rootDir, "versioning_linking.gradle") apply plugin: 'com.apollographql.apollo' +expoModule { + canBePublished false +} def reactProperties = new Properties() file("${project(':packages:react-native:ReactAndroid').projectDir}/gradle.properties").withInputStream { reactProperties.load(it) } @@ -36,18 +40,11 @@ group = 'host.exp.exponent' version = '45.0.0' // WHEN_VERSIONING_REMOVE_TO_HERE -def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") -apply from: expoModulesCorePlugin -applyKotlinExpoModulesCorePlugin() -useDefaultAndroidSdkVersions() -useExpoPublishing() - repositories { mavenCentral() maven { url "https://jitpack.io" } } - apply plugin: 'com.facebook.react' apply plugin: 'org.jetbrains.kotlin.plugin.compose' diff --git a/apps/expo-go/android/sdkVersions.json b/apps/expo-go/android/sdkVersions.json deleted file mode 100644 index 33ce855256f04e..00000000000000 --- a/apps/expo-go/android/sdkVersions.json +++ /dev/null @@ -1 +0,0 @@ -{"sdkVersions":["55.0.0"]} diff --git a/apps/expo-go/app.json b/apps/expo-go/app.json deleted file mode 100644 index 5a555065afc6c3..00000000000000 --- a/apps/expo-go/app.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "expo": { - "name": "expo-home", - "description": "", - "slug": "home", - "privacy": "unlisted", - "sdkVersion": "55.0.0", - "version": "55.0.0", - "platforms": ["ios", "android"], - "primaryColor": "#cccccc", - "icon": "https://s3.amazonaws.com/exp-brand-assets/ExponentEmptyManifest_192.png", - "updates": { - "checkAutomatically": "NEVER", - "fallbackToCacheTimeout": 0, - "url": "https://u.expo.dev/6b6c6660-df76-11e6-b9b4-59d1587e6774" - }, - "userInterfaceStyle": "automatic", - "ios": { - "supportsTablet": true, - "bundleIdentifier": "host.exp.exponent" - }, - "newArchEnabled": true, - "android": { - "package": "host.exp.exponent" - }, - "androidStatusBar": { - "barStyle": "dark-content" - }, - "scheme": "exp", - "jsEngine": "hermes", - "extra": { - "eas": { - "projectId": "6b6c6660-df76-11e6-b9b4-59d1587e6774" - } - }, - "runtimeVersion": "55.0.0" - } -} diff --git a/apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh b/apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh index 608883b0b91793..2f72e0ce4d1e81 100755 --- a/apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh +++ b/apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh @@ -55,7 +55,7 @@ EXPO_DIR="$(cd "$IOS_DIR/../../.." && pwd)" # Key paths EXPO_GO_DIR="$EXPO_DIR/apps/expo-go" -APP_JSON="$EXPO_GO_DIR/app.json" +SDK_VERSIONS_JSON="$EXPO_GO_DIR/sdkVersions.json" INFO_PLIST="$IOS_DIR/Exponent/Supporting/Info.plist" BUILD_CONSTANTS_PLIST="$IOS_DIR/Exponent/Supporting/EXBuildConstants.plist" TEMPLATE_FILES_DIR="$EXPO_DIR/template-files" @@ -219,12 +219,12 @@ resolve_macros() { fi echo "Resolved TEST_RUN_ID macro to \"$TEST_RUN_ID\"" - # TEMPORARY_SDK_VERSION (from app.json) - if [[ -f "$APP_JSON" ]]; then - TEMPORARY_SDK_VERSION=$(json_read "$APP_JSON" ".expo.sdkVersion") + # TEMPORARY_SDK_VERSION (from sdkVersions.json) + if [[ -f "$SDK_VERSIONS_JSON" ]]; then + TEMPORARY_SDK_VERSION=$(json_read "$SDK_VERSIONS_JSON" ".sdkVersion") else TEMPORARY_SDK_VERSION="" - echo "Warning: app.json not found at $APP_JSON" + echo "Warning: sdkVersions.json not found at $SDK_VERSIONS_JSON" fi echo "Resolved TEMPORARY_SDK_VERSION macro to \"$TEMPORARY_SDK_VERSION\"" diff --git a/apps/expo-go/ios/Exponent/Supporting/sdkVersions.json b/apps/expo-go/ios/Exponent/Supporting/sdkVersions.json deleted file mode 100644 index 0f1f02e93954f6..00000000000000 --- a/apps/expo-go/ios/Exponent/Supporting/sdkVersions.json +++ /dev/null @@ -1 +0,0 @@ -{"sdkVersions":["55.0.0"]} \ No newline at end of file diff --git a/apps/expo-go/ios/Podfile.lock b/apps/expo-go/ios/Podfile.lock index 01d8227dde525a..a79bce31d9ffbe 100644 --- a/apps/expo-go/ios/Podfile.lock +++ b/apps/expo-go/ios/Podfile.lock @@ -4742,7 +4742,7 @@ SPEC CHECKSUMS: ExpoVideoThumbnails: 7e5f6bddec993b7930b41fd47646a27e4f8133e9 ExpoWebBrowser: 19c5d250e0c101027677970a5f2fc635d9df2e73 EXStructuredHeaders: aa49a5557fa24aa61dda4ac665f3987bf3e9e35d - EXUpdates: 5b1b025edd0e9596b8633a62e0792c17f0e8c932 + EXUpdates: 427abdf3ad98db45733c1497cf60ce04c179dc8f EXUpdatesInterface: 26412751a0f7a7130614655929e316f684552aab fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: f1200e6ef6cf24885501668bdbb9eff4cf48843f @@ -4759,7 +4759,7 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 8a82b93a6400c8e6551c0bcd66a9177f2e067aed GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d - hermes-engine: ca6495d9d859ae100566305c4d0afe7a0c777a46 + hermes-engine: a785894172be9ea26a2d808760e5954874576bd8 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 diff --git a/apps/expo-go/sdkVersions.json b/apps/expo-go/sdkVersions.json new file mode 100644 index 00000000000000..cad5581d7fae32 --- /dev/null +++ b/apps/expo-go/sdkVersions.json @@ -0,0 +1 @@ +{"sdkVersion":"55.0.0"} diff --git a/apps/expo-go/tsconfig.json b/apps/expo-go/tsconfig.json deleted file mode 100644 index e2d5212e4d76f1..00000000000000 --- a/apps/expo-go/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "expo-module-scripts/tsconfig.base", - "compilerOptions": { - "resolveJsonModule": true, - "baseUrl": ".", - "noEmit": true, - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitAny": true - }, - "include": ["App.tsx", "src/**/*", "ts-declarations"], - "exclude": ["**/__mocks__/*"] -} diff --git a/packages/expo-module-template-local/android/build.gradle b/packages/expo-module-template-local/android/build.gradle index 7705ebb73b6fc2..2b4a191ec607e3 100644 --- a/packages/expo-module-template-local/android/build.gradle +++ b/packages/expo-module-template-local/android/build.gradle @@ -1,36 +1,11 @@ -apply plugin: 'com.android.library' +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} group = '<%- project.package %>' version = '0.7.8' -def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") -apply from: expoModulesCorePlugin -applyKotlinExpoModulesCorePlugin() -useCoreDependencies() -useExpoPublishing() - -// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. -// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. -// Most of the time, you may like to manage the Android SDK versions yourself. -def useManagedAndroidSdkVersions = false -if (useManagedAndroidSdkVersions) { - useDefaultAndroidSdkVersions() -} else { - buildscript { - // Simple helper that allows the root project to override versions declared by this library. - ext.safeExtGet = { prop, fallback -> - rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback - } - } - project.android { - compileSdkVersion safeExtGet("compileSdkVersion", 36) - defaultConfig { - minSdkVersion safeExtGet("minSdkVersion", 24) - targetSdkVersion safeExtGet("targetSdkVersion", 36) - } - } -} - android { namespace "<%- project.package %>" defaultConfig { diff --git a/packages/expo-module-template/android/build.gradle b/packages/expo-module-template/android/build.gradle index 5aafb1a67345e2..e2ff5dd182899a 100644 --- a/packages/expo-module-template/android/build.gradle +++ b/packages/expo-module-template/android/build.gradle @@ -1,36 +1,11 @@ -apply plugin: 'com.android.library' +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} group = '<%- project.package %>' version = '<%- project.version %>' -def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") -apply from: expoModulesCorePlugin -applyKotlinExpoModulesCorePlugin() -useCoreDependencies() -useExpoPublishing() - -// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. -// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. -// Most of the time, you may like to manage the Android SDK versions yourself. -def useManagedAndroidSdkVersions = false -if (useManagedAndroidSdkVersions) { - useDefaultAndroidSdkVersions() -} else { - buildscript { - // Simple helper that allows the root project to override versions declared by this library. - ext.safeExtGet = { prop, fallback -> - rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback - } - } - project.android { - compileSdkVersion safeExtGet("compileSdkVersion", 36) - defaultConfig { - minSdkVersion safeExtGet("minSdkVersion", 24) - targetSdkVersion safeExtGet("targetSdkVersion", 36) - } - } -} - android { namespace "<%- project.package %>" defaultConfig { diff --git a/tools/src/Npm.ts b/tools/src/Npm.ts index 8136980a358a23..f5a7cbe118d572 100644 --- a/tools/src/Npm.ts +++ b/tools/src/Npm.ts @@ -240,7 +240,14 @@ export async function grantReadWriteAccessAsync( packageName: string, teamName: string ): Promise { - await spawnAsync('npm', ['access', 'grant', 'read-write', teamName, packageName]); + await spawnAsync('npm', [ + 'access', + 'grant', + 'read-write', + teamName, + packageName, + ...maybeNpmOtpFlag(), + ]); } /** diff --git a/tools/src/ProjectVersions.ts b/tools/src/ProjectVersions.ts index 29eb75ab399e4e..062915a0cb0251 100644 --- a/tools/src/ProjectVersions.ts +++ b/tools/src/ProjectVersions.ts @@ -7,15 +7,15 @@ import semver from 'semver'; import { EXPO_DIR, EXPO_GO_ANDROID_DIR, - PACKAGES_DIR, - EXPO_GO_IOS_DIR, EXPO_GO_DIR, + EXPO_GO_IOS_DIR, + PACKAGES_DIR, } from './Constants'; export type Platform = 'ios' | 'android'; -export type SDKVersionsObject = { - sdkVersions: string[]; +type SDKVersionsObject = { + sdkVersion: string; }; const BUNDLED_NATIVE_MODULES_PATH = path.join(PACKAGES_DIR, 'expo', 'bundledNativeModules.json'); @@ -54,45 +54,31 @@ export async function androidAppVersionAsync(): Promise { return match[1]; } -export async function getExpoGoSDKVersionAsync(): Promise { - const expoGoAppJsonPath = path.join(EXPO_GO_DIR, 'app.json'); - const appJson = (await JsonFile.readAsync(expoGoAppJsonPath, { json5: true })) as any; - - if (appJson?.expo?.sdkVersion) { - return appJson.expo.sdkVersion as string; - } - throw new Error(`Home's SDK version not found!`); -} - -export async function getSDKVersionsAsync(platform: Platform): Promise { - const appDir = - platform === 'ios' ? path.join(EXPO_GO_IOS_DIR, 'Exponent', 'Supporting') : EXPO_GO_ANDROID_DIR; - const sdkVersionsPath = path.join(appDir, 'sdkVersions.json'); +export async function getSDKVersionAsync(_platform?: Platform): Promise { + const sdkVersionsPath = path.join(EXPO_GO_DIR, 'sdkVersions.json'); if (!(await fs.pathExists(sdkVersionsPath))) { throw new Error(`File at path "${sdkVersionsPath}" not found.`); } - const { sdkVersions } = (await JsonFile.readAsync(sdkVersionsPath)) as SDKVersionsObject; - return sdkVersions; + const { sdkVersion } = (await JsonFile.readAsync(sdkVersionsPath)) as SDKVersionsObject; + return sdkVersion; } -export async function getOldestSDKVersionAsync(platform: Platform): Promise { - const sdkVersions = await getSDKVersionsAsync(platform); - return sdkVersions.sort(semver.compare)[0]; +export async function getSDKVersionsAsync(platform: Platform): Promise { + return [await getSDKVersionAsync(platform)]; } -export async function getNewestSDKVersionAsync(platform: Platform): Promise { - const sdkVersions = await getSDKVersionsAsync(platform); - return sdkVersions.sort(semver.rcompare)[0]; +export async function getOldestSDKVersionAsync(platform: Platform): Promise { + return await getSDKVersionAsync(platform); } -export async function getNextSDKVersionAsync(platform: Platform): Promise { - const newestVersion = await getNewestSDKVersionAsync(platform); +export async function getNewestSDKVersionAsync(platform: Platform): Promise { + return await getSDKVersionAsync(platform); +} - if (!newestVersion) { - return; - } - return `${semver.major(semver.inc(newestVersion, 'major')!)}.0.0`; +export async function getNextSDKVersionAsync(platform: Platform): Promise { + const currentVersion = await getSDKVersionAsync(platform); + return `${semver.major(semver.inc(currentVersion, 'major')!)}.0.0`; } /** @@ -101,12 +87,9 @@ export async function getNextSDKVersionAsync(platform: Platform): Promise { - if (sdkVersion === 'latest') { - return await getNewestSDKVersionAsync(platform); - } - if (sdkVersion === 'oldest') { - return await getOldestSDKVersionAsync(platform); +): Promise { + if (sdkVersion === 'latest' || sdkVersion === 'oldest') { + return await getSDKVersionAsync(platform); } if (sdkVersion === 'next') { return await getNextSDKVersionAsync(platform); diff --git a/tools/src/commands/CheckSdkPrsCommand.ts b/tools/src/commands/CheckSdkPrsCommand.ts new file mode 100644 index 00000000000000..5a7ae75c1a23e5 --- /dev/null +++ b/tools/src/commands/CheckSdkPrsCommand.ts @@ -0,0 +1,388 @@ +import { Command } from '@expo/commander'; +import chalk from 'chalk'; + +import Git from '../Git'; +import logger from '../Logger'; +import { spawnJSONCommandAsync } from '../Utils'; +import { getVersionsAsync, getSortedSdkVersionKeys, VersionsApiHost } from '../Versions'; + +type GhPr = { + number: number; + title: string; + state: 'OPEN' | 'MERGED' | 'CLOSED'; + mergeCommit: { oid: string } | null; + author: { login: string }; + mergedAt: string | null; + updatedAt: string; + reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | ''; + url: string; +}; + +type ActionOptions = { + all: boolean; + links: boolean; +}; + +let linksEnabled = true; + +function link(text: string, url: string): string { + if (!linksEnabled) { + return text; + } + // Lazy require so FORCE_HYPERLINK is set before supports-hyperlinks evaluates + if (!process.env.FORCE_HYPERLINK) { + process.env.FORCE_HYPERLINK = '1'; + } + const terminalLink: typeof import('terminal-link') = require('terminal-link'); + return terminalLink(text, url, { fallback: false }); +} + +function parseSDKArg(sdk: string | undefined): string | null { + if (!sdk) { + return null; + } + const match = sdk.match(/^(?:sdk-)?(\d+)$/); + return match ? match[1] : null; +} + +function formatAge(mergedAt: string): string { + const ms = Date.now() - new Date(mergedAt).getTime(); + const days = Math.floor(ms / 86400000); + if (days === 0) { + return 'today'; + } + if (days === 1) { + return '1d ago'; + } + if (days < 30) { + return `${days}d ago`; + } + return `${Math.floor(days / 7)}w ago`; +} + +/** + * Parses "Publish packages" commits on the SDK branch to build a set of + * package directory names that have been published (e.g. "expo-camera", "@expo/cli"). + */ +async function getPublishedPackages(ref: string): Promise> { + // Use %H%n%b to get commit hash followed by body for each publish commit + const { stdout } = await Git.runAsync([ + 'log', + ref, + '--grep=Publish packages', + '--pretty=format:%H%n%b', + ]); + // Map package name → most recent publish commit hash + const published = new Map(); + let currentHash = ''; + for (const line of stdout.split('\n')) { + if (/^[0-9a-f]{40}$/.test(line)) { + currentHash = line; + continue; + } + // Lines look like: "expo-camera@55.0.9" or "@expo/cli@0.22.14" + const match = line.match(/^(.+)@[\d.]+$/); + if (match && currentHash) { + // git log is newest-first; overwriting gives us the earliest publish per package + published.set(match[1], currentHash); + } + } + return published; +} + +/** + * Returns the set of package directory names touched by a merge commit. + * Maps file paths like "packages/expo-camera/src/foo.ts" to "expo-camera", + * and "packages/@expo/cli/src/bar.ts" to "@expo/cli". + */ +async function getPackagesTouchedByCommit(oid: string): Promise> { + const { stdout } = await Git.runAsync(['diff-tree', '--no-commit-id', '-r', '--name-only', oid]); + const packages = new Set(); + for (const file of stdout.split('\n')) { + // packages/expo-camera/... or packages/@expo/cli/... + const match = file.match(/^packages\/((?:@[^/]+\/)?[^/]+)\//); + if (match) { + packages.add(match[1]); + } + } + return packages; +} + +type PublishInfo = + | { status: 'published'; commitHash: string } + | { status: 'unpublished' | 'no-packages' }; + +async function getPrPublishInfo( + pr: GhPr, + publishedPackages: Map +): Promise { + if (!pr.mergeCommit) { + return { status: 'no-packages' }; + } + const touched = await getPackagesTouchedByCommit(pr.mergeCommit.oid); + if (touched.size === 0) { + return { status: 'no-packages' }; + } + const allPublished = [...touched].every((pkg) => publishedPackages.has(pkg)); + if (!allPublished) { + return { status: 'unpublished' }; + } + // Link to the first publish commit that included one of the touched packages + const commitHash = [...touched].map((pkg) => publishedPackages.get(pkg)).find(Boolean)!; + return { status: 'published', commitHash }; +} + +function formatPublishInfo(info: PublishInfo): string { + switch (info.status) { + case 'published': + return link( + chalk.green('published'), + `https://github.com/expo/expo/commit/${info.commitHash}` + ); + case 'unpublished': + return chalk.dim('unpublished'); + case 'no-packages': + return ''; + } +} + +// Matches SGR codes (\x1b[...m) and OSC 8 hyperlinks (\x1b]8;;...\x07) +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m|\x1b\]8;;[^\x07]*\x07/g; + +function pad(str: string, width: number): string { + const visible = str.replace(ANSI_RE, '').length; + return str + ' '.repeat(Math.max(0, width - visible)); +} + +function formatReviewDecision(decision: GhPr['reviewDecision']): string { + switch (decision) { + case 'APPROVED': + return chalk.green('approved'); + case 'CHANGES_REQUESTED': + return chalk.red('changes requested'); + case 'REVIEW_REQUIRED': + return chalk.yellow('review needed'); + default: + return ''; + } +} + +function formatPrLine(pr: GhPr, publishInfo?: PublishInfo): string { + const num = link(chalk.yellow(`#${pr.number}`), pr.url); + const sha = pr.mergeCommit + ? link( + chalk.dim(pr.mergeCommit.oid.slice(0, 10)), + `https://github.com/expo/expo/commit/${pr.mergeCommit.oid}` + ) + : ''; + // For merged PRs: publish status. For open PRs: review decision. + let status = ''; + if (pr.state === 'MERGED' && publishInfo) { + status = formatPublishInfo(publishInfo); + } else if (pr.state === 'OPEN') { + status = formatReviewDecision(pr.reviewDecision); + } + const titleText = pr.title.length > 60 ? pr.title.slice(0, 57) + '...' : pr.title; + const title = link(titleText, pr.url); + const author = link(chalk.dim(`@${pr.author.login}`), `https://github.com/${pr.author.login}`); + // For merged PRs show merge age, for open PRs show time since last activity + const timestamp = pr.mergedAt ?? pr.updatedAt; + const age = timestamp ? chalk.dim(formatAge(timestamp)) : ''; + return ` ${pad(num, 8)} ${pad(sha, 10)} ${pad(status, 17)} ${pad(title, 60)} ${pad(author, 20)} ${age}`; +} + +async function action(sdk: string | undefined, options: ActionOptions) { + linksEnabled = options.links; + + // Resolve SDK version + let sdkNumber = parseSDKArg(sdk); + + if (!sdkNumber) { + const currentBranch = await Git.getCurrentBranchNameAsync(); + const branchMatch = currentBranch.match(/\bsdk-(\d+)$/); + if (branchMatch) { + sdkNumber = branchMatch[1]; + } else { + // Not on an SDK branch — get the latest SDK version from the versions endpoint + const versions = await getVersionsAsync(VersionsApiHost.PRODUCTION); + const sorted = getSortedSdkVersionKeys(versions); + + if (sorted.length === 0) { + throw new Error( + `Could not detect SDK version. Pass it explicitly: ${chalk.bold('et csp 55')}` + ); + } + sdkNumber = sorted[0].split('.')[0]; + logger.info(`Detected latest SDK version: ${chalk.bold(sdkNumber)}`); + } + } + + const label = `sdk-${sdkNumber}`; + const branch = `sdk-${sdkNumber}`; + + // Fetch PRs from GitHub + logger.info(`Fetching PRs labeled ${chalk.bold(label)}...`); + + let prs: GhPr[]; + try { + prs = await spawnJSONCommandAsync('gh', [ + 'pr', + 'list', + '--repo', + 'expo/expo', + '--label', + label, + '--state', + 'all', + '--json', + 'number,title,state,mergeCommit,author,mergedAt,updatedAt,reviewDecision,url', + '--limit', + '500', + ]); + } catch (error: any) { + if (error.message?.includes('ENOENT') || error.message?.includes('not found')) { + throw new Error(`GitHub CLI (gh) is not installed. Install it from https://cli.github.com/`); + } + throw error; + } + + if (prs.length === 0) { + logger.warn(`No PRs found with label ${chalk.bold(label)}.`); + return; + } + + const mergedPrs = prs.filter((pr) => pr.state === 'MERGED'); + const openPrs = prs.filter((pr) => pr.state === 'OPEN'); + const closedPrs = prs.filter((pr) => pr.state === 'CLOSED'); + + // Fetch SDK branch + logger.info(`Fetching ${chalk.bold(branch)} branch...`); + await Git.fetchAsync({ remote: 'origin', ref: branch }); + + // Determine which ref to compare against + let ref = `origin/${branch}`; + const localBranchExists = await Git.tryAsync(['rev-parse', '--verify', branch]); + + if (localBranchExists) { + try { + const { ahead } = await Git.compareBranchesAsync(branch, `origin/${branch}`); + if (ahead > 0) { + logger.warn( + `Local branch ${chalk.bold(branch)} is ${ahead} commit${ahead === 1 ? '' : 's'} ahead of origin — comparing against local.` + ); + ref = branch; + } + } catch { + // If comparison fails, fall back to remote + } + } + + // Get all commit subjects from the SDK branch + const { stdout } = await Git.runAsync(['log', ref, '--pretty=format:%s']); + const cherryPickedPrs = new Set(); + + for (const line of stdout.split('\n')) { + const match = line.match(/\(#(\d+)\)/); + if (match) { + cherryPickedPrs.add(Number(match[1])); + } + } + + const needsCherryPick = mergedPrs.filter((pr) => !cherryPickedPrs.has(pr.number)); + const alreadyCherryPicked = mergedPrs.filter((pr) => cherryPickedPrs.has(pr.number)); + + // Determine publish status for cherry-picked PRs + const publishedPackages = await getPublishedPackages(ref); + const publishInfos = new Map(); + await Promise.all( + alreadyCherryPicked.map(async (pr) => { + publishInfos.set(pr.number, await getPrPublishInfo(pr, publishedPackages)); + }) + ); + + // Sort by mergedAt descending (most recent first) + needsCherryPick.sort((a, b) => new Date(b.mergedAt!).getTime() - new Date(a.mergedAt!).getTime()); + alreadyCherryPicked.sort( + (a, b) => new Date(b.mergedAt!).getTime() - new Date(a.mergedAt!).getTime() + ); + + // Display results + if (options.all) { + if (needsCherryPick.length > 0) { + logger.log(''); + logger.log(chalk.red.bold(`Needs Cherry-Pick (${needsCherryPick.length}):`)); + for (const pr of needsCherryPick) { + logger.log(formatPrLine(pr, publishInfos.get(pr.number))); + } + } + + if (alreadyCherryPicked.length > 0) { + logger.log(''); + logger.log(chalk.green.bold(`Already Cherry-Picked (${alreadyCherryPicked.length}):`)); + for (const pr of alreadyCherryPicked) { + logger.log(formatPrLine(pr, publishInfos.get(pr.number))); + } + } + + if (openPrs.length > 0) { + logger.log(''); + logger.log(chalk.blue.bold(`Open (${openPrs.length}):`)); + for (const pr of openPrs) { + logger.log(formatPrLine(pr, publishInfos.get(pr.number))); + } + } + + if (closedPrs.length > 0) { + logger.log(''); + logger.log(chalk.gray.bold(`Closed without Merge (${closedPrs.length}):`)); + for (const pr of closedPrs) { + logger.log(formatPrLine(pr, publishInfos.get(pr.number))); + } + } + } else { + if (needsCherryPick.length === 0) { + logger.success( + `All merged PRs have been cherry-picked to ${chalk.bold(branch)}! (${mergedPrs.length} merged, ${openPrs.length} open)` + ); + return; + } + + logger.log(''); + logger.log(chalk.bold(`PRs that need cherry-picking to ${branch}:`)); + logger.log(''); + for (const pr of needsCherryPick) { + logger.log(formatPrLine(pr, publishInfos.get(pr.number))); + } + } + + // Summary + logger.log(''); + logger.log( + chalk.dim( + `${needsCherryPick.length} PR${needsCherryPick.length === 1 ? '' : 's'} need${needsCherryPick.length === 1 ? 's' : ''} cherry-picking (${mergedPrs.length} merged, ${openPrs.length} open)` + ) + ); +} + +export default (program: Command) => { + program + .command('check-sdk-prs [sdk]') + .alias('csp') + .description( + `Shows which PRs labeled for an SDK version still need cherry-picking to the SDK branch. + + If [sdk] is omitted, detects from the current branch (e.g. sdk-55) or falls back + to the latest SDK version from the versions endpoint. Accepts "55" or "sdk-55". + + Cherry-pick detection: matches PR numbers in commit subjects (the "(#N)" suffix + GitHub adds to squash-merge titles) against commits on the SDK branch. + + Publish detection: parses "Publish packages" commit bodies on the SDK branch to + find which packages have been published, then checks which packages each PR touched. + A PR is "published" if all packages it modified have appeared in a publish commit.` + ) + .option('-a, --all', 'Show all PRs grouped by status', false) + .option('--no-links', 'Disable clickable terminal hyperlinks') + .asyncAction(action); +}; diff --git a/tools/src/commands/PublishPackages.ts b/tools/src/commands/PublishPackages.ts index 60264ce2543c58..92d2c4d25bd3de 100644 --- a/tools/src/commands/PublishPackages.ts +++ b/tools/src/commands/PublishPackages.ts @@ -59,6 +59,11 @@ export default (program: Command) => { 'Include expo-module-scripts in publishing (excluded by default).', false ) + .option( + '--cascade-all', + 'Include dependents of shared tooling packages (babel-preset-expo, jest-expo, etc.) that are normally excluded from cascading.', + false + ) /* exclusive options */ .option( diff --git a/tools/src/dynamic-macros/macros.ts b/tools/src/dynamic-macros/macros.ts index cfdb641aff3391..5dc67fab8ccf3d 100644 --- a/tools/src/dynamic-macros/macros.ts +++ b/tools/src/dynamic-macros/macros.ts @@ -12,7 +12,7 @@ import path from 'path'; import { EXPO_GO_DIR, EXPO_GO_DEV_SERVER_PORT } from '../Constants'; import { getExpoRepositoryRootDir } from '../Directories'; -import { getExpoGoSDKVersionAsync } from '../ProjectVersions'; +import { getSDKVersionAsync } from '../ProjectVersions'; interface Manifest { id: string; @@ -199,7 +199,7 @@ export default { } }, - async TEMPORARY_SDK_VERSION(): Promise { - return await getExpoGoSDKVersionAsync(); + async TEMPORARY_SDK_VERSION(platform): Promise { + return await getSDKVersionAsync(platform); }, }; diff --git a/tools/src/promote-packages/tasks/findPackagesToPromote.ts b/tools/src/promote-packages/tasks/findPackagesToPromote.ts index 64f1bf7bffb00a..4db93caff733da 100644 --- a/tools/src/promote-packages/tasks/findPackagesToPromote.ts +++ b/tools/src/promote-packages/tasks/findPackagesToPromote.ts @@ -25,7 +25,15 @@ export const findPackagesToPromote = new Task( state.distTags = currentDistTags; state.versionToReplace = versionToReplace; - state.isDemoting = !!versionToReplace && semver.lt(pkg.packageVersion, versionToReplace); + // A canary version (e.g. 56.0.0-canary-20260212-4f61309) should always be + // considered less than any non-canary version for promotion purposes. + const replacingCanary = + !!versionToReplace && + (semver.prerelease(versionToReplace)?.[0] as string)?.startsWith('canary'); + state.isDemoting = + !!versionToReplace && + semver.lt(pkg.packageVersion, versionToReplace) && + !replacingCanary; if (canPromote && (!state.isDemoting || options.list || options.demote)) { newParcels.push(parcel); diff --git a/tools/src/publish-packages/constants.ts b/tools/src/publish-packages/constants.ts index b4bdda37f2a145..b53bf9e96bfdbd 100644 --- a/tools/src/publish-packages/constants.ts +++ b/tools/src/publish-packages/constants.ts @@ -34,3 +34,20 @@ export const BACKUPABLE_OPTIONS_FIELDS = [ * An array of release types in the order from patch to major. */ export const RELEASE_TYPES_ASC_ORDER = [ReleaseType.PATCH, ReleaseType.MINOR, ReleaseType.MAJOR]; + +/** + * Shared tooling packages that should not trigger the "publish dependents?" cascade. + * These packages are depended on by nearly every package in the monorepo, so any change + * to them would otherwise prompt to republish dozens of packages unnecessarily. + * + * They are still published normally when they have changes — they just don't cascade. + * Use `--cascade-all` to override this filter. + */ +export const NON_CASCADING_PACKAGES = new Set([ + 'expo-module-scripts', + 'babel-preset-expo', + 'eslint-config-universe', + 'jest-expo', + '@expo/config', + '@expo/json-file', +]); diff --git a/tools/src/publish-packages/tasks/grantTeamAccessToPackages.ts b/tools/src/publish-packages/tasks/grantTeamAccessToPackages.ts index 3072ad855ae85c..d8856554f21e91 100644 --- a/tools/src/publish-packages/tasks/grantTeamAccessToPackages.ts +++ b/tools/src/publish-packages/tasks/grantTeamAccessToPackages.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { loadRequestedParcels } from './loadRequestedParcels'; import logger from '../../Logger'; import * as Npm from '../../Npm'; +import { withOtpRetry } from '../../NpmOtp'; import { Task } from '../../TasksRunner'; import { CommandOptions, Parcel, TaskArgs } from '../types'; @@ -39,7 +40,9 @@ export const grantTeamAccessToPackages = new Task( if (!options.dry) { for (const packageName of packagesToGrantAccess) { try { - await Npm.grantReadWriteAccessAsync(packageName, Npm.EXPO_DEVELOPERS_TEAM_NAME); + await withOtpRetry(() => + Npm.grantReadWriteAccessAsync(packageName, Npm.EXPO_DEVELOPERS_TEAM_NAME) + ); } catch (e) { logger.debug(e.stderr || e.stdout); logger.error(`šŸŽ– Granting access to ${green(packageName)} failed`); diff --git a/tools/src/publish-packages/tasks/selectPackagesToPublish.ts b/tools/src/publish-packages/tasks/selectPackagesToPublish.ts index f005db7f635337..8eb59e19424adc 100644 --- a/tools/src/publish-packages/tasks/selectPackagesToPublish.ts +++ b/tools/src/publish-packages/tasks/selectPackagesToPublish.ts @@ -12,6 +12,7 @@ import logger from '../../Logger'; import { Task } from '../../TasksRunner'; import { runWithSpinner } from '../../Utils'; import { PackagesGraphNode } from '../../packages-graph'; +import { NON_CASCADING_PACKAGES } from '../constants'; import { getSuggestedVersions, isParcelUnpublished, @@ -117,10 +118,23 @@ export const selectPackagesToPublish = new Task( // This call mutates `parcelsToPublish` set, adding the selected parcels. await selectParcelsToPublish(parcelsToSelect, parcelsToPublish, options); + // Filter out non-cascading shared packages unless --cascade-all is set. + const cascadingParcels = parcels.filter( + (parcel) => parcel.state.isRequested && parcelsToPublish.has(parcel) + ); + const skippedFromCascade = cascadingParcels.filter( + (p) => !options.cascadeAll && NON_CASCADING_PACKAGES.has(p.pkg.packageName) + ); + if (skippedFromCascade.length > 0) { + logger.info( + `\nšŸ“¦ Skipping dependent cascade for shared packages: ${skippedFromCascade.map((p) => green(p.pkg.packageName)).join(', ')}\n Use ${chalk.bold('--cascade-all')} to include their dependents.` + ); + } + // A set of graph nodes representing the dependents of the selected packages. const dependentNodes = new Set( - parcels - .filter((parcel) => parcel.state.isRequested && parcelsToPublish.has(parcel)) + cascadingParcels + .filter((p) => !skippedFromCascade.includes(p)) .map((parcel) => parcel.graphNode.getAllDependents()) .flat() // If templates-only, do not suggest dependents since we are restricting to templates diff --git a/tools/src/publish-packages/types.ts b/tools/src/publish-packages/types.ts index d09609cc13fbef..8756641468ce7a 100644 --- a/tools/src/publish-packages/types.ts +++ b/tools/src/publish-packages/types.ts @@ -23,6 +23,8 @@ export type CommandOptions = { templatesOnly: boolean; /** Include expo-module-scripts in publishing (excluded by default) */ includeExpoModuleScripts: boolean; + /** Bypass the non-cascading package filter and cascade dependents for all packages */ + cascadeAll: boolean; skipAndroidArtifacts: boolean; /** * When true, automatically selects packages whose current package.json version