diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 968ddf9d..5a101470 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,8 +34,74 @@ concurrency: cancel-in-progress: false jobs: + validate-react-native-release: + name: Validate React Native release package + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Skip React Native publish validation + if: ${{ inputs.platform != 'React Native' || inputs.mode == 'Dry run' }} + run: echo "No React Native npm publish validation needed for ${{ inputs.platform }} / ${{ inputs.mode }}." + + - name: Checkout + if: ${{ inputs.platform == 'React Native' && inputs.mode != 'Dry run' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Validate requested React Native release + if: ${{ inputs.platform == 'React Native' && inputs.mode != 'Dry run' }} + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: .github/scripts/validate-release-version "React Native" "$EXPECTED_VERSION" >/dev/null + + - name: Setup Node.js, pnpm, and install dependencies + if: ${{ inputs.platform == 'React Native' && inputs.mode != 'Dry run' }} + uses: ./.github/actions/setup + with: + node-version-file: platforms/react-native/package.json + cache-dependency-path: platforms/react-native/pnpm-lock.yaml + package-json-file: platforms/react-native/package.json + working-directory: platforms/react-native + ignore-scripts: "true" + + - name: Verify React Native version is not already published + if: ${{ inputs.platform == 'React Native' && inputs.mode != 'Dry run' }} + working-directory: platforms/react-native + run: | + set -euo pipefail + NAME=$(node -p "require('./modules/@shopify/checkout-kit-react-native/package.json').name") + VERSION=$(node -p "require('./modules/@shopify/checkout-kit-react-native/package.json').version") + ENCODED_NAME=$(node -p "encodeURIComponent(require('./modules/@shopify/checkout-kit-react-native/package.json').name)") + URL="https://registry.npmjs.org/${ENCODED_NAME}/${VERSION}" + if curl -fs "$URL" > /dev/null; then + echo "::error::${NAME}@${VERSION} is already published on npm. Bump platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json before creating a GitHub Release." + exit 1 + fi + echo "::notice::${NAME}@${VERSION} is not yet on npm — safe to create a release." + + - name: Validate React Native package publish shape + if: ${{ inputs.platform == 'React Native' && inputs.mode != 'Dry run' }} + working-directory: platforms/react-native + env: + KEEP_TMP: "0" + PACKAGE_MANAGERS: pnpm + run: pnpm publish:check:package + + - name: Validate generated React Native consumer app + if: ${{ inputs.platform == 'React Native' && inputs.mode != 'Dry run' }} + working-directory: platforms/react-native + env: + BUILD_NATIVE: "0" + KEEP_TMP: "0" + PACKAGE_MANAGER: pnpm + PLATFORM: ios + run: pnpm publish:check:react-native + release: name: Release ${{ inputs.platform }} ${{ inputs.version }} + needs: validate-react-native-release runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/rn-check-packed-files.yml b/.github/workflows/rn-check-packed-files.yml index e29d9eba..d06b59ac 100644 --- a/.github/workflows/rn-check-packed-files.yml +++ b/.github/workflows/rn-check-packed-files.yml @@ -11,7 +11,7 @@ jobs: check-packed-files: name: Check package files runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 env: TERM: xterm defaults: @@ -33,3 +33,9 @@ jobs: pnpm module clean pnpm module build pnpm compare-snapshot + + - name: Validate package publish shape + env: + KEEP_TMP: "0" + PACKAGE_MANAGERS: pnpm + run: pnpm publish:check:package diff --git a/platforms/react-native/.eslintignore b/platforms/react-native/.eslintignore new file mode 100644 index 00000000..ee41331b --- /dev/null +++ b/platforms/react-native/.eslintignore @@ -0,0 +1 @@ +scripts/publish_checks/support/templates/**/* diff --git a/platforms/react-native/RELEASE.md b/platforms/react-native/RELEASE.md new file mode 100644 index 00000000..85e17dcf --- /dev/null +++ b/platforms/react-native/RELEASE.md @@ -0,0 +1,232 @@ +# React Native publish checks + +This package is published from the pnpm workspace, but it consumes generated +protocol TypeScript from the repository-local `@shopify/checkout-kit-protocol` +workspace package. That protocol package is intentionally not published to npm. + +## Why protocol is a devDependency and a bundledDependency + +The React Native package imports protocol code at runtime: + +```ts +import {CheckoutProtocol} from '@shopify/checkout-kit-react-native'; +``` + +Internally, the built package still imports `@shopify/checkout-kit-protocol`. +For consumers to install the React Native package without publishing protocol as +a standalone public package, the protocol package must be physically included in +the packed React Native tarball. + +The package manifest uses this shape: + +```json +{ + "dependencies": {}, + "devDependencies": { + "@shopify/checkout-kit-protocol": "workspace:*" + }, + "bundledDependencies": [ + "@shopify/checkout-kit-protocol" + ] +} +``` + +This is non-obvious but intentional: + +- `devDependencies` keeps the workspace protocol package available while + building and packing inside this repository. +- `bundledDependencies` makes `pnpm pack` include the protocol package inside + the tarball at: + + ```text + node_modules/@shopify/checkout-kit-protocol + ``` + +- The published React Native package resolves protocol at runtime from its own + nested `node_modules`, not from the consumer app's dependency graph. + +We avoid listing protocol in normal `dependencies` because `pnpm pack` rewrites +`workspace:*` to the protocol package version (`0.0.0`). With: + +```json +"dependencies": { + "@shopify/checkout-kit-protocol": "0.0.0" +} +``` + +`npm` and `pnpm` can install the tarball when the dependency is bundled, but +Yarn classic and Bun still try to fetch `@shopify/checkout-kit-protocol@0.0.0` +from the registry. Since protocol is unpublished, those installs fail. + +The publish checks below verify that the tarball is installable and that runtime +resolution finds the bundled protocol package. + +## Release-gating checks + +Run these from `platforms/react-native`. + +### Full publish check + +```bash +pnpm publish:check +``` + +`publish:check` runs the package check, the React Native consumer check, and the +Expo consumer check. This is intended as the full release-gating check for the +React Native npm package. + +The checks are also available individually: + +```bash +pnpm publish:check:package +pnpm publish:check:react-native +pnpm publish:check:expo +``` + +All checks validate the packed tarball, not local workspace imports. + +## Individual checks + +### Package install check + +```bash +PACKAGE_MANAGERS=all pnpm publish:check:package +``` + +This builds the RN package, packs it, inspects the tarball manifest, checks for +unresolved local dependency specs (`workspace:`, `file:`, `link:`, `portal:`), +and installs the tarball in clean consumer projects. + +To test a single package manager: + +```bash +PACKAGE_MANAGERS=pnpm pnpm publish:check:package +PACKAGE_MANAGERS=npm pnpm publish:check:package +PACKAGE_MANAGERS=yarn pnpm publish:check:package +PACKAGE_MANAGERS=bun pnpm publish:check:package +``` + +If the repo's dev environment wraps `npm`, point to a real npm binary: + +```bash +NPM_BIN=/path/to/npm PACKAGE_MANAGERS=npm pnpm publish:check:package +``` + +### React Native consumer check + +```bash +PACKAGE_MANAGER=pnpm PLATFORM=ios pnpm publish:check:react-native +``` + +This creates a throwaway React Native app, installs the packed tarball, bundles a +protocol runtime entry with Metro, executes the generated bundle, and bundles a +public API entry. + +This check does not currently install a native app on a simulator/device. It is a +Metro/runtime consumer check for the published JavaScript package shape. + +Examples: + +```bash +PACKAGE_MANAGER=yarn PLATFORM=ios pnpm publish:check:react-native +PACKAGE_MANAGER=bun PLATFORM=android pnpm publish:check:react-native +``` + +### Expo development-client consumer check + +```bash +PACKAGE_MANAGER=pnpm PLATFORM=ios pnpm publish:check:expo +``` + +This uses Expo CLI to create a fresh Expo app, installs the packed tarball, +adds `expo-dev-client`, writes a basic screen that can create a Storefront cart +and launch checkout, verifies the bundled protocol package, runs Expo prebuild, +and compiles the native project. For iOS it runs `xcodebuild`; for Android it +runs Gradle. It does not launch Metro, a simulator, or a device by default, so +it exits cleanly in CI. + +The generated Expo app uses distinct native identifiers so it does not overwrite +other smoke apps: + +```text +iOS bundle identifier: com.shopify.checkoutkitexposmoke +Android package: com.shopify.checkoutkitexposmoke +``` + +Useful variants: + +```bash +# iOS native compile only; no Metro/simulator launch. +PACKAGE_MANAGER=pnpm PLATFORM=ios pnpm publish:check:expo + +# Android native compile only; no Metro/device launch. +PACKAGE_MANAGER=pnpm PLATFORM=android pnpm publish:check:expo + +# Create/install/prebuild, but skip the native compile. +PACKAGE_MANAGER=pnpm PLATFORM=ios BUILD_NATIVE=0 pnpm publish:check:expo + +# Launch the app after the native compile for manual smoke testing. +PACKAGE_MANAGER=pnpm PLATFORM=ios RUN_APP=1 pnpm publish:check:expo + +# Use a known checkout URL instead of creating a Storefront cart. +PACKAGE_MANAGER=pnpm CHECKOUT_URL="https://example.myshopify.com/checkouts/..." pnpm publish:check:expo + +# Use different Storefront API values. +PACKAGE_MANAGER=pnpm \ +STOREFRONT_DOMAIN="example.myshopify.com" \ +STOREFRONT_ACCESS_TOKEN="public-storefront-token" \ +STOREFRONT_VERSION="2025-07" \ +pnpm publish:check:expo +``` + +Generated app checks preserve their temporary app directory by default and print +the path. Set `KEEP_TMP=0` if you want the generated app removed after a +successful run. You can then run a preserved app manually, for example: + +```bash +cd /tmp/checkout-kit-expo-smoke... +pnpm expo run:ios +``` + +## Package-manager configuration + +The package check accepts `PACKAGE_MANAGERS` because it can validate multiple +installers in one run: + +```bash +PACKAGE_MANAGERS="pnpm npm yarn bun" pnpm publish:check:package +PACKAGE_MANAGERS=all pnpm publish:check:package +``` + +The consumer app checks accept one `PACKAGE_MANAGER` per run: + +```bash +PACKAGE_MANAGER=pnpm pnpm publish:check:react-native +PACKAGE_MANAGER=yarn pnpm publish:check:expo +``` + +This shape maps naturally to a future CI matrix. + +## Expo Go vs Expo development client + +`@shopify/checkout-kit-react-native` includes custom native code, so it cannot +run in Expo Go. The Expo consumer check uses a development build by installing +`expo-dev-client`, running `expo prebuild`, and then compiling the generated +native project with `xcodebuild` or Gradle. Set `RUN_APP=1` when you want the +check to launch the app with `expo run:ios` or `expo run:android` after the +native compile. + +If you see: + +```text +No script URL provided. Make sure the packager is running or you have embedded a JS bundle +``` + +make sure the generated app has `expo-dev-client` installed and has been +prebuilt/rebuilt after installing it: + +```bash +pnpm add expo-dev-client +pnpm expo prebuild --clean +pnpm expo run:ios +``` diff --git a/platforms/react-native/package.json b/platforms/react-native/package.json index 06cd6aee..db77334a 100644 --- a/platforms/react-native/package.json +++ b/platforms/react-native/package.json @@ -23,6 +23,10 @@ "pod-install": "bash ./scripts/pod_install", "snapshot": "./scripts/create_snapshot", "compare-snapshot": "./scripts/compare_snapshot", + "publish:check": "pnpm publish:check:package && pnpm publish:check:react-native && pnpm publish:check:expo", + "publish:check:package": "./scripts/validate_release package", + "publish:check:react-native": "./scripts/validate_release react-native", + "publish:check:expo": "./scripts/validate_release expo", "turbo": "turbo", "test": "jest" }, diff --git a/platforms/react-native/scripts/publish_checks/commands/clean.rb b/platforms/react-native/scripts/publish_checks/commands/clean.rb new file mode 100644 index 00000000..ebb51494 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/commands/clean.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'optparse' + +require_relative '../support/shared' +require_relative '../support/temp_workspace' + +module PublishChecks + module Commands + class Clean + def self.run(argv, react_native_root:) + new(argv).run + end + + def initialize(argv) + @dry_run = false + parse!(argv) + end + + def run + ConfigSummary.print( + 'Clean release validation configuration', + values: {dry_run: @dry_run, temp_root: TempWorkspace.root} + ) + + unless TempWorkspace.clean!(dry_run: @dry_run) + puts "Nothing to clean: #{TempWorkspace.root}" + end + end + + private + + def parse!(argv) + OptionParser.new do |opts| + opts.banner = 'Usage: validate_release clean [options]' + opts.separator '' + opts.separator 'Removes generated apps, install fixtures, logs, and default pack output created by validate_release.' + opts.on('--dry-run', 'Print the temp directory without deleting it') { @dry_run = true } + opts.on('-h', '--help', 'Show this help') do + puts opts + exit 0 + end + end.parse!(argv) + + raise "Unknown arguments: #{argv.join(' ')}" unless argv.empty? + end + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/commands/expo_app.rb b/platforms/react-native/scripts/publish_checks/commands/expo_app.rb new file mode 100644 index 00000000..0fb9c0b5 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/commands/expo_app.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'json' +require 'optparse' + +require_relative '../support/app_generators/expo_app_generator' +require_relative '../support/checkout_kit_packager' +require_relative '../support/shared' +require_relative '../support/generated_app' +require_relative '../support/native_build' +require_relative '../support/package_manager' +require_relative '../support/package_verifier' +require_relative '../support/temp_workspace' + +module PublishChecks + module Commands + class ExpoApp + APP_NAME = 'CheckoutKitExpoSmoke' + APP_SLUG = 'checkout-kit-expo-smoke' + APP_SCHEME = 'checkoutkitexposmoke' + APP_PACKAGE = 'com.shopify.checkoutkitexposmoke' + + def self.run(argv, react_native_root:) + new(argv, react_native_root: react_native_root).run + end + + def initialize(argv, react_native_root:) + @react_native_root = react_native_root + @options = default_options + parse!(argv) + end + + def run + validate_options! + pm = PackageManager.new(name: @options[:package_manager], registry: @options[:registry]) + pm.ensure_available! + print_configuration(pm) + + app = nil + success = false + + begin + tarball = CheckoutKitPackager.new( + react_native_root: @react_native_root, + pack_dir: pack_dir(pm) + ).pack + + app = GeneratedApp.create( + app_dir: @options[:app_dir], + app_name: APP_NAME, + keep_on_success: @options[:keep_tmp], + replace_existing: @options[:replace_app_dir], + temp_namespace: ['expo', pm.namespace] + ) + app.prepare! + + ExpoAppGenerator.new( + template: @options[:expo_template], + registry: @options[:registry] + ).generate(app.path) + + Shell.section('Writing Expo smoke app files') + pm.write_config(app.path) + configure_package_json(app.path) + configure_app_json(app.path) + write_smoke_config(app.path) + write_app_tsx(app.path) + + pm.install(app.path) + pm.add(app.path, "file:#{tarball}") + install_expo_dev_client_if_needed(pm, app.path) + + PackageVerifier.verify_installed_tarball(app.path) + + if @options[:prebuild] + Shell.section("Running Expo prebuild for #{@options[:platform]}") + pm.exec(app.path, 'expo', 'prebuild', '--platform', @options[:platform], '--clean') + else + Shell.section("PREBUILD=0; skipping expo prebuild for #{@options[:platform]}") + end + + if @options[:build_native] + NativeBuild.new( + app_dir: app.path, + platform: @options[:platform], + ios_configuration: @options[:ios_configuration], + ios_destination: @options[:ios_destination], + android_gradle_task: @options[:android_gradle_task] + ).build + else + Shell.section("BUILD_NATIVE=0; skipping native #{@options[:platform]} build") + end + + run_app_if_requested(pm, app.path) + + success = true + Shell.section('Expo generated app release validation passed') + puts "App dir: #{app.path}" + puts "Tarball: #{tarball}" + puts @options[:build_native] ? "Native #{@options[:platform]} build completed." : "Native #{@options[:platform]} build was skipped." + unless @options[:run_app] + puts 'Metro and simulator/device launch were skipped.' + puts 'Run the app manually with:' + puts " cd #{app.path}" + puts " #{File.basename(pm.binary)} expo run:#{@options[:platform]}" + end + ensure + app&.cleanup(success: success) + end + end + + private + + def default_options + { + app_dir: ENV.fetch('APP_DIR', nil), + pack_dir: ENV['PACK_DIR'], + keep_tmp: Env.bool('KEEP_TMP', true), + replace_app_dir: Env.bool('REPLACE_APP_DIR', false), + package_manager: ENV.fetch('PACKAGE_MANAGER', 'pnpm'), + registry: ENV.fetch('INSTALL_REGISTRY', 'https://registry.npmjs.org/'), + platform: ENV.fetch('PLATFORM', 'ios'), + prebuild: Env.bool('PREBUILD', true), + build_native: Env.bool('BUILD_NATIVE', true), + run_app: Env.bool('RUN_APP', false), + install_expo_dev_client: Env.bool('INSTALL_EXPO_DEV_CLIENT', true), + expo_template: ENV.fetch('EXPO_TEMPLATE', 'expo-template-blank-typescript@sdk-55'), + ios_configuration: ENV.fetch('IOS_CONFIGURATION', 'Debug'), + ios_destination: ENV.fetch('IOS_XCODEBUILD_DESTINATION', 'generic/platform=iOS Simulator'), + android_gradle_task: ENV.fetch('ANDROID_GRADLE_TASK', ':app:assembleDebug'), + ios_simulator: ENV.fetch('IOS_SIMULATOR', nil), + android_device: ENV.fetch('ANDROID_DEVICE', nil), + storefront_domain: config_value('STOREFRONT_DOMAIN'), + storefront_access_token: config_value('STOREFRONT_ACCESS_TOKEN'), + storefront_version: config_value('STOREFRONT_VERSION', fallback_key: 'API_VERSION', default: '2024-04'), + checkout_url: config_value('CHECKOUT_URL') + } + end + + def root_env + @root_env ||= Dotenv.read(File.join(repo_root, '.env')) + end + + def react_native_sample_env + @react_native_sample_env ||= Dotenv.read(File.join(@react_native_root, 'sample/.env')) + end + + def repo_root + @repo_root ||= File.expand_path('../..', @react_native_root) + end + + def config_value(key, fallback_key: nil, default: '') + config_entry(key, fallback_key: fallback_key)&.fetch(:value) || default + end + + def config_source(key, fallback_key: nil) + config_entry(key, fallback_key: fallback_key)&.fetch(:source) || 'not configured' + end + + def config_entry(key, fallback_key: nil) + config_keys = [key, fallback_key].compact + config_sources = [ + [ENV, 'environment'], + [root_env, 'root .env'], + [react_native_sample_env, 'platforms/react-native/sample/.env'] + ] + + config_sources.each do |source, source_name| + config_keys.each do |config_key| + value = source[config_key] + return {value: value, source: source_name} unless value.nil? || value.empty? + end + end + + nil + end + + def print_configuration(package_manager) + ConfigSummary.print( + 'Expo release validation configuration', + values: @options.merge( + package_manager_namespace: package_manager.namespace, + resolved_package_manager_binary: package_manager.resolved_binary, + resolved_pack_dir: pack_dir(package_manager) + ) + ) + end + + def parse!(argv) + OptionParser.new do |opts| + opts.banner = 'Usage: validate_release expo [options]' + opts.separator '' + opts.separator 'Generates a fresh Expo app with create-expo-app, installs the packed tarball, and runs Expo/native smoke checks.' + + opts.on('--template TEMPLATE', 'Expo template passed to create-expo-app') { |value| @options[:expo_template] = value } + opts.on('--platform PLATFORM', 'ios or android') { |value| @options[:platform] = value } + opts.on('--package-manager NAME', 'pnpm, npm, yarn, or bun') { |value| @options[:package_manager] = value } + opts.on('--app-dir PATH', 'Use a specific generated app directory') { |value| @options[:app_dir] = value } + opts.on('--pack-dir PATH', 'Directory for the packed tarball') { |value| @options[:pack_dir] = value } + opts.on('--registry URL', 'Registry used for package installs') { |value| @options[:registry] = value } + opts.on('--verbose', 'Print resolved configuration and stream stdout from package managers, app generators, and build tools') { Shell.verbose = true } + opts.on('--cleanup', 'Delete temp-created generated app on success') { @options[:keep_tmp] = false } + opts.on('--keep-tmp', 'Preserve generated app on success') { @options[:keep_tmp] = true } + opts.on('--replace-app-dir', 'Remove an existing APP_DIR before generating the app') { @options[:replace_app_dir] = true } + opts.on('--prebuild', 'Run expo prebuild') { @options[:prebuild] = true } + opts.on('--skip-prebuild', 'Skip expo prebuild') { @options[:prebuild] = false } + opts.on('--build-native', 'Compile the generated native app') { @options[:build_native] = true } + opts.on('--skip-native-build', 'Skip native compilation') { @options[:build_native] = false } + opts.on('--run-app', 'Launch the generated app on simulator/device') { @options[:run_app] = true } + opts.on('--skip-run-app', 'Skip simulator/device launch') { @options[:run_app] = false } + opts.on('--install-expo-dev-client', 'Install expo-dev-client using Expo CLI') { @options[:install_expo_dev_client] = true } + opts.on('--skip-expo-dev-client', 'Do not install expo-dev-client') { @options[:install_expo_dev_client] = false } + opts.on('--ios-configuration CONFIGURATION', 'xcodebuild configuration') { |value| @options[:ios_configuration] = value } + opts.on('--ios-destination DESTINATION', 'xcodebuild destination') { |value| @options[:ios_destination] = value } + opts.on('--android-gradle-task TASK', 'Android Gradle task') { |value| @options[:android_gradle_task] = value } + opts.separator '' + opts.separator 'Environment:' + opts.separator ' APP_DIR, PACK_DIR, KEEP_TMP, PACKAGE_MANAGER, INSTALL_REGISTRY, PLATFORM, VERBOSE' + opts.separator ' Package manager binaries: PNPM_BIN, NPM_BIN, YARN_BIN, BUN_BIN' + opts.separator ' Bun exec binary: BUNX_BIN' + opts.separator ' create-expo-app npm binary: CREATE_EXPO_APP_NPM_BIN' + opts.separator ' Expo/native: EXPO_TEMPLATE, PREBUILD, BUILD_NATIVE, RUN_APP, IOS_CONFIGURATION, IOS_XCODEBUILD_DESTINATION, ANDROID_GRADLE_TASK' + opts.separator ' Launch config: STOREFRONT_DOMAIN, STOREFRONT_ACCESS_TOKEN, CHECKOUT_URL, STOREFRONT_VERSION/API_VERSION from env, root .env, or sample/.env' + opts.on('-h', '--help', 'Show this help') do + puts opts + exit 0 + end + end.parse!(argv) + + raise "Unknown arguments: #{argv.join(' ')}" unless argv.empty? + end + + def validate_options! + raise "PLATFORM must be ios or android, got #{@options[:platform].inspect}" unless %w[ios android].include?(@options[:platform]) + end + + def pack_dir(package_manager) + @options[:pack_dir] || TempWorkspace.default_pack_dir('expo', package_manager.namespace) + end + + def configure_package_json(app_dir) + JsonFile.update(File.join(app_dir, 'package.json')) do |pkg| + pkg['scripts'] ||= {} + pkg['scripts']['bundle:ios'] = 'expo export --platform ios --output-dir dist-ios --clear' + pkg['scripts']['bundle:android'] = 'expo export --platform android --output-dir dist-android --clear' + end + end + + def configure_app_json(app_dir) + JsonFile.update(File.join(app_dir, 'app.json')) do |app| + app['expo'] ||= {} + app['expo']['name'] = APP_NAME + app['expo']['slug'] = APP_SLUG + app['expo']['scheme'] = APP_SCHEME + app['expo']['newArchEnabled'] = true + app['expo']['ios'] ||= {} + app['expo']['ios']['bundleIdentifier'] = APP_PACKAGE + app['expo']['android'] ||= {} + app['expo']['android']['package'] = APP_PACKAGE + end + end + + def write_smoke_config(app_dir) + config = { + storefrontDomain: @options[:storefront_domain], + storefrontAccessToken: @options[:storefront_access_token], + storefrontVersion: @options[:storefront_version], + checkoutUrl: @options[:checkout_url] + } + File.write(File.join(app_dir, 'smoke.config.json'), "#{JSON.pretty_generate(config)}\n") + + puts "Storefront domain: #{config[:storefrontDomain].empty? ? 'missing' : config[:storefrontDomain]} from #{config_source('STOREFRONT_DOMAIN')}" + puts "Storefront access token: #{Redaction.configured_summary(config[:storefrontAccessToken], label: 'token')} from #{config_source('STOREFRONT_ACCESS_TOKEN')}" + puts "Checkout URL: #{Redaction.presence(config[:checkoutUrl])}" + puts "Storefront API version: #{config[:storefrontVersion]} from #{config_source('STOREFRONT_VERSION', fallback_key: 'API_VERSION')}" + end + + def write_app_tsx(app_dir) + template = File.expand_path('../support/templates/expo/App.tsx', __dir__) + FileUtils.cp(template, File.join(app_dir, 'App.tsx')) + end + + def install_expo_dev_client_if_needed(pm, app_dir) + return unless @options[:install_expo_dev_client] + return unless @options[:prebuild] || @options[:run_app] + + Shell.section('Installing expo-dev-client with Expo CLI') + pm.exec(app_dir, 'expo', 'install', 'expo-dev-client') + end + + def run_app_if_requested(pm, app_dir) + unless @options[:run_app] + Shell.section("RUN_APP=0; skipping expo run:#{@options[:platform]}") + return + end + + Shell.section("Running Expo app on #{@options[:platform]}") + if @options[:platform] == 'ios' + args = ['expo', 'run:ios'] + args.concat(['--simulator', @options[:ios_simulator]]) if @options[:ios_simulator] && !@options[:ios_simulator].empty? + pm.exec(app_dir, *args) + else + args = ['expo', 'run:android'] + args.concat(['--device', @options[:android_device]]) if @options[:android_device] && !@options[:android_device].empty? + pm.exec(app_dir, *args) + end + end + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/commands/package_check.rb b/platforms/react-native/scripts/publish_checks/commands/package_check.rb new file mode 100644 index 00000000..b7ae1c71 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/commands/package_check.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'json' +require 'optparse' +require 'tmpdir' + +require_relative '../support/checkout_kit_packager' +require_relative '../support/shared' +require_relative '../support/package_manager' +require_relative '../support/packed_tarball' +require_relative '../support/temp_workspace' + +module PublishChecks + module Commands + class PackageCheck + PACKAGE_NAME = PackedTarball::PACKAGE_NAME + PROTOCOL_PACKAGE = PackedTarball::PROTOCOL_PACKAGE + SUPPORTED_MANAGERS = %w[pnpm npm yarn bun].freeze + + def self.run(argv, react_native_root:) + new(argv, react_native_root: react_native_root).run + end + + def initialize(argv, react_native_root:) + @react_native_root = react_native_root + @options = default_options + @install_dirs = [] + parse!(argv) + end + + def run + managers = expand_package_managers(@options[:package_managers]) + raise 'PACKAGE_MANAGERS must include at least one package manager' if managers.empty? + + package_managers = managers.map do |manager_name| + PackageManager.new(name: manager_name, registry: @options[:registry]).tap(&:ensure_available!) + end + print_configuration(package_managers) + + tarball_path = CheckoutKitPackager.new( + react_native_root: @react_native_root, + pack_dir: pack_dir(package_managers) + ).pack(dry_run: true) + + tarball = PackedTarball.new(tarball_path) + tarball.print_contents if Shell.verbose? + tarball.print_packed_manifest_summary if Shell.verbose? + tarball.validate_manifest! + + puts "Package managers to test: #{managers.join(' ')}" + + success = false + begin + package_managers.each do |package_manager| + manager_name = package_manager.name + install_dir = create_install_fixture(package_manager) + @install_dirs << install_dir + write_install_fixture_project(install_dir) + + install_tarball(package_manager, tarball.path, install_dir) + + if Shell.verbose? + Shell.section("Installed package manifest dependency summary (#{manager_name})") + print_installed_manifest_summary(install_dir, manager_name) + end + verify_bundled_protocol_installed(install_dir, tarball, manager_name) + rescue StandardError => e + puts + puts "#{manager_name} clean package install failed. The tarball may still depend on packages that are not published/installable from #{@options[:registry]}, or this package manager may not support the package shape." + puts "Tarball: #{tarball.path}" + raise e + end + + success = true + Shell.section('Publish package check passed') + puts "Packed tarball is installable from clean install fixtures with: #{managers.join(' ')}" + puts "Tarball: #{tarball.path}" + ensure + cleanup_install_dirs(success: success) + end + end + + private + + def default_options + { + pack_dir: ENV['PACK_DIR'], + registry: ENV.fetch('INSTALL_REGISTRY', 'https://registry.npmjs.org/'), + keep_tmp: Env.bool('KEEP_TMP', false), + package_managers: ENV.fetch('PACKAGE_MANAGERS', 'pnpm') + } + end + + def print_configuration(package_managers) + ConfigSummary.print( + 'Package release validation configuration', + values: @options.merge( + package_manager_namespaces: package_managers.map(&:namespace), + package_manager_binaries: package_managers.to_h { |package_manager| [package_manager.name, package_manager.resolved_binary] }, + resolved_pack_dir: pack_dir(package_managers) + ) + ) + end + + def parse!(argv) + OptionParser.new do |opts| + opts.banner = 'Usage: validate_release package [options]' + opts.separator '' + opts.separator 'Packs @shopify/checkout-kit-react-native and verifies the tarball installs from clean install fixtures.' + + opts.on('--package-managers LIST', 'Package managers to test: comma/space list or all') { |value| @options[:package_managers] = value } + opts.on('--pack-dir PATH', 'Directory for the packed tarball') { |value| @options[:pack_dir] = value } + opts.on('--registry URL', 'Registry used for package installs') { |value| @options[:registry] = value } + opts.on('--verbose', 'Print resolved configuration and stream stdout from package managers and pack commands') { Shell.verbose = true } + opts.on('--cleanup', 'Delete install fixtures on success') { @options[:keep_tmp] = false } + opts.on('--keep-tmp', 'Preserve install fixtures on success') { @options[:keep_tmp] = true } + opts.separator '' + opts.separator 'Environment:' + opts.separator ' PACKAGE_MANAGERS, PACK_DIR, INSTALL_REGISTRY, KEEP_TMP, VERBOSE' + opts.separator ' Package manager binaries: PNPM_BIN, NPM_BIN, YARN_BIN, BUN_BIN' + opts.separator ' Bun exec binary: BUNX_BIN' + opts.on('-h', '--help', 'Show this help') do + puts opts + exit 0 + end + end.parse!(argv) + + raise "Unknown arguments: #{argv.join(' ')}" unless argv.empty? + end + + def pack_dir(package_managers) + namespace = package_managers.map(&:namespace).join('+') + @options[:pack_dir] || TempWorkspace.default_pack_dir('package', namespace) + end + + def expand_package_managers(requested) + managers = requested.to_s.tr(',', ' ').split + managers = SUPPORTED_MANAGERS if managers == ['all'] + unsupported = managers - SUPPORTED_MANAGERS + unless unsupported.empty? + raise "Unsupported package manager(s): #{unsupported.join(', ')}. Use one of: #{SUPPORTED_MANAGERS.join(', ')}, all" + end + managers + end + + def create_install_fixture(package_manager) + TempWorkspace.mktmpdir( + :installs, + "react-native-publish-install-#{package_manager.name}.", + namespace: [package_manager.namespace] + ) + end + + def write_install_fixture_project(install_dir) + FileUtils.mkdir_p(install_dir) + File.write(File.join(install_dir, 'package.json'), <<~JSON) + { + "name": "react-native-publish-install-check", + "version": "1.0.0", + "private": true + } + JSON + end + + def install_tarball(package_manager, tarball, install_dir) + package_manager.write_config(install_dir) + + Shell.section("Attempting clean package install with #{package_manager.name}") + Shell.detail("Install fixture: #{install_dir}") + Shell.detail("Registry: #{@options[:registry]}") + Shell.detail("Namespace: #{package_manager.namespace}") + Shell.detail("Binary: #{package_manager.resolved_binary}") + + package_manager.add(install_dir, tarball) + end + + def print_installed_manifest_summary(install_dir, manager_name) + manifest_path = File.join(install_dir, 'node_modules', PACKAGE_NAME, 'package.json') + unless File.file?(manifest_path) + raise "#{manager_name} install completed, but #{manifest_path} was not found" + end + + manifest = JSON.parse(File.read(manifest_path)) + puts JSON.pretty_generate( + { + name: manifest['name'], + version: manifest['version'], + dependencies: manifest['dependencies'], + devDependencies: manifest['devDependencies'], + peerDependencies: manifest['peerDependencies'], + bundledDependencies: manifest['bundledDependencies'] || manifest['bundleDependencies'] + } + ) + end + + def verify_bundled_protocol_installed(install_dir, tarball, manager_name) + return unless tarball.references_protocol_package? + + package_root = File.realpath(File.join(install_dir, 'node_modules', PACKAGE_NAME)) + protocol_manifest = File.join(package_root, 'node_modules', PROTOCOL_PACKAGE, 'package.json') + unless File.file?(protocol_manifest) + raise "#{manager_name} install completed, but bundled #{PROTOCOL_PACKAGE} was not installed at #{protocol_manifest}" + end + + resolved = resolve_node_package(PROTOCOL_PACKAGE, from: File.join(package_root, 'lib/commonjs')) + expected_prefix = File.realpath(File.join(package_root, 'node_modules')) + unless resolved.start_with?(expected_prefix) + raise "#{PROTOCOL_PACKAGE} resolved outside the installed RN package: #{resolved}" + end + + Shell.detail("Node-style resolver finds bundled protocol manifest at: #{resolved}") + puts "#{manager_name} installed bundled #{PROTOCOL_PACKAGE}." + end + + def resolve_node_package(package_name, from:) + current = File.expand_path(from) + loop do + candidate = File.join(current, 'node_modules', package_name, 'package.json') + return File.realpath(candidate) if File.file?(candidate) + + parent = File.dirname(current) + break if parent == current + + current = parent + end + + raise "Could not resolve #{package_name} from #{from}" + end + + def cleanup_install_dirs(success:) + @install_dirs.each do |install_dir| + if success && !@options[:keep_tmp] + FileUtils.rm_rf(install_dir) + elsif File.directory?(install_dir) + puts "Package install fixture preserved at: #{install_dir}" + end + end + end + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/commands/react_native_app.rb b/platforms/react-native/scripts/publish_checks/commands/react_native_app.rb new file mode 100644 index 00000000..5e553dd7 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/commands/react_native_app.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'json' +require 'optparse' + +require_relative '../support/app_generators/react_native_app_generator' +require_relative '../support/checkout_kit_packager' +require_relative '../support/shared' +require_relative '../support/generated_app' +require_relative '../support/native_build' +require_relative '../support/package_manager' +require_relative '../support/package_verifier' +require_relative '../support/temp_workspace' + +module PublishChecks + module Commands + class ReactNativeApp + DEFAULT_REACT_NATIVE_VERSION = '0.80.2' + APP_NAME = ReactNativeAppGenerator::APP_NAME + + def self.run(argv, react_native_root:) + new(argv, react_native_root: react_native_root).run + end + + def initialize(argv, react_native_root:) + @react_native_root = react_native_root + @options = default_options + parse!(argv) + end + + def run + validate_options! + pm = PackageManager.new(name: @options[:package_manager], registry: @options[:registry]) + pm.ensure_available! + print_configuration(pm) + + app = nil + success = false + + begin + tarball = CheckoutKitPackager.new( + react_native_root: @react_native_root, + pack_dir: pack_dir(pm) + ).pack + + app = GeneratedApp.create( + app_dir: @options[:app_dir], + app_name: APP_NAME, + keep_on_success: @options[:keep_tmp], + replace_existing: @options[:replace_app_dir], + temp_namespace: ['react-native', pm.namespace] + ) + app.prepare! + + ReactNativeAppGenerator.new( + version: @options[:react_native_version], + template: @options[:react_native_template], + registry: @options[:registry] + ).generate(app.path) + + Shell.section('Writing React Native smoke app files') + pm.write_config(app.path) + configure_package_json(app.path) + write_protocol_runtime_entry(app.path) + write_public_api_entry(app.path) + FileUtils.mkdir_p(File.join(app.path, 'dist')) + + pm.install(app.path) + pm.add(app.path, "file:#{tarball}") + + PackageVerifier.verify_installed_tarball(app.path) + + Shell.section('Bundling and executing protocol-only runtime smoke entry') + pm.run_script(app.path, 'bundle:protocol') + Shell.run('node', "dist/protocol-runtime.#{@options[:platform]}.jsbundle", chdir: app.path) + + Shell.section('Bundling public React Native app entry') + pm.run_script(app.path, 'bundle:public') + + if @options[:build_native] + install_pods_if_needed(app.path) + NativeBuild.new( + app_dir: app.path, + platform: @options[:platform], + ios_configuration: @options[:ios_configuration], + ios_destination: @options[:ios_destination], + android_gradle_task: @options[:android_gradle_task] + ).build + else + Shell.section("BUILD_NATIVE=0; skipping native #{@options[:platform]} build") + end + + success = true + Shell.section('React Native generated app release validation passed') + puts 'Created a throwaway RN app with React Native CLI, installed the tarball, bundled the protocol runtime entry, executed it, and bundled a public API app entry.' + puts "Tarball: #{tarball}" + puts "App dir: #{app.path}" + ensure + app&.cleanup(success: success) + end + end + + private + + def default_options + { + app_dir: ENV.fetch('APP_DIR', nil), + pack_dir: ENV['PACK_DIR'], + keep_tmp: Env.bool('KEEP_TMP', true), + replace_app_dir: Env.bool('REPLACE_APP_DIR', false), + package_manager: ENV.fetch('PACKAGE_MANAGER', 'pnpm'), + registry: ENV.fetch('INSTALL_REGISTRY', 'https://registry.npmjs.org/'), + platform: ENV.fetch('PLATFORM', 'ios'), + react_native_version: ENV.fetch('REACT_NATIVE_VERSION', DEFAULT_REACT_NATIVE_VERSION), + react_native_template: ENV.fetch('REACT_NATIVE_TEMPLATE', nil), + build_native: Env.bool('BUILD_NATIVE', false), + ios_configuration: ENV.fetch('IOS_CONFIGURATION', 'Debug'), + ios_destination: ENV.fetch('IOS_XCODEBUILD_DESTINATION', 'generic/platform=iOS Simulator'), + android_gradle_task: ENV.fetch('ANDROID_GRADLE_TASK', ':app:assembleDebug') + } + end + + def print_configuration(package_manager) + ConfigSummary.print( + 'React Native release validation configuration', + values: @options.merge( + package_manager_namespace: package_manager.namespace, + resolved_package_manager_binary: package_manager.resolved_binary, + resolved_pack_dir: pack_dir(package_manager) + ) + ) + end + + def parse!(argv) + OptionParser.new do |opts| + opts.banner = 'Usage: validate_release react-native [options]' + opts.separator '' + opts.separator 'Generates a fresh React Native app with React Native CLI, installs the packed tarball, and runs bundle/native smoke checks.' + + opts.on('--version VERSION', 'React Native version passed to React Native CLI init') { |value| @options[:react_native_version] = value } + opts.on('--template TEMPLATE', 'React Native template passed to React Native CLI init') { |value| @options[:react_native_template] = value } + opts.on('--platform PLATFORM', 'ios or android') { |value| @options[:platform] = value } + opts.on('--package-manager NAME', 'pnpm, npm, yarn, or bun') { |value| @options[:package_manager] = value } + opts.on('--app-dir PATH', 'Use a specific generated app directory') { |value| @options[:app_dir] = value } + opts.on('--pack-dir PATH', 'Directory for the packed tarball') { |value| @options[:pack_dir] = value } + opts.on('--registry URL', 'Registry used for package installs') { |value| @options[:registry] = value } + opts.on('--verbose', 'Print resolved configuration and stream stdout from package managers, app generators, and build tools') { Shell.verbose = true } + opts.on('--cleanup', 'Delete temp-created generated app on success') { @options[:keep_tmp] = false } + opts.on('--keep-tmp', 'Preserve generated app on success') { @options[:keep_tmp] = true } + opts.on('--replace-app-dir', 'Remove an existing APP_DIR before generating the app') { @options[:replace_app_dir] = true } + opts.on('--build-native', 'Compile the generated native app after JS bundle checks') { @options[:build_native] = true } + opts.on('--skip-native-build', 'Skip native compilation') { @options[:build_native] = false } + opts.on('--ios-configuration CONFIGURATION', 'xcodebuild configuration') { |value| @options[:ios_configuration] = value } + opts.on('--ios-destination DESTINATION', 'xcodebuild destination') { |value| @options[:ios_destination] = value } + opts.on('--android-gradle-task TASK', 'Android Gradle task') { |value| @options[:android_gradle_task] = value } + opts.separator '' + opts.separator 'Environment:' + opts.separator ' APP_DIR, PACK_DIR, KEEP_TMP, PACKAGE_MANAGER, INSTALL_REGISTRY, PLATFORM, VERBOSE' + opts.separator ' Package manager binaries: PNPM_BIN, NPM_BIN, YARN_BIN, BUN_BIN' + opts.separator ' Bun exec binary: BUNX_BIN' + opts.separator ' React Native CLI: REACT_NATIVE_VERSION, REACT_NATIVE_TEMPLATE, REACT_NATIVE_CLI_PACKAGE, CREATE_REACT_NATIVE_APP_NPM_BIN' + opts.separator ' Native build: BUILD_NATIVE, IOS_CONFIGURATION, IOS_XCODEBUILD_DESTINATION, ANDROID_GRADLE_TASK' + opts.on('-h', '--help', 'Show this help') do + puts opts + exit 0 + end + end.parse!(argv) + + raise "Unknown arguments: #{argv.join(' ')}" unless argv.empty? + end + + def validate_options! + raise "PLATFORM must be ios or android, got #{@options[:platform].inspect}" unless %w[ios android].include?(@options[:platform]) + end + + def pack_dir(package_manager) + @options[:pack_dir] || TempWorkspace.default_pack_dir('react-native', package_manager.namespace) + end + + def configure_package_json(app_dir) + platform = @options[:platform] + JsonFile.update(File.join(app_dir, 'package.json')) do |pkg| + pkg['scripts'] ||= {} + pkg['scripts']['bundle:protocol'] = "react-native bundle --entry-file index.protocol-runtime.js --platform #{platform} --dev false --bundle-output dist/protocol-runtime.#{platform}.jsbundle --assets-dest dist/assets-protocol --reset-cache" + pkg['scripts']['bundle:public'] = "react-native bundle --entry-file index.public-api.js --platform #{platform} --dev false --bundle-output dist/public-api.#{platform}.jsbundle --assets-dest dist/assets-public --reset-cache" + pkg['scripts']['run:protocol-bundle'] = "node dist/protocol-runtime.#{platform}.jsbundle" + end + end + + def write_protocol_runtime_entry(app_dir) + File.write(File.join(app_dir, 'index.protocol-runtime.js'), <<~'JS') + import { + CheckoutProtocol, + decodeProtocolPayload, + } from '@shopify/checkout-kit-react-native/lib/module/protocol'; + + if (CheckoutProtocol.start !== 'ec.start') { + throw new Error(`Unexpected CheckoutProtocol.start: ${CheckoutProtocol.start}`); + } + + const decoded = decodeProtocolPayload(CheckoutProtocol.error, { + messages: [], + ucp: { + version: '2026-04-08', + status: 'error', + payment_handlers: {}, + }, + }); + + if (decoded?.ucp?.status !== 'error') { + throw new Error(`Unexpected decoded protocol payload: ${JSON.stringify(decoded)}`); + } + + console.log('checkout-kit protocol runtime smoke ok', { + checkoutProtocol: CheckoutProtocol, + decodedErrorStatus: decoded.ucp.status, + }); + JS + end + + def write_public_api_entry(app_dir) + File.write(File.join(app_dir, 'index.public-api.js'), <<~JS) + import React from 'react'; + import {AppRegistry, Text, View} from 'react-native'; + import {CheckoutProtocol} from '@shopify/checkout-kit-react-native'; + + function App() { + return React.createElement( + View, + null, + React.createElement(Text, null, `Protocol start: ${CheckoutProtocol.start}`), + ); + } + + AppRegistry.registerComponent('#{APP_NAME}', () => App); + JS + end + + def install_pods_if_needed(app_dir) + return unless @options[:platform] == 'ios' + return unless File.exist?(File.join(app_dir, 'ios/Podfile')) + + Shell.section('Installing React Native iOS pods') + Shell.run('pod', 'install', chdir: File.join(app_dir, 'ios')) + end + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/app_generators/expo_app_generator.rb b/platforms/react-native/scripts/publish_checks/support/app_generators/expo_app_generator.rb new file mode 100644 index 00000000..27612839 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/app_generators/expo_app_generator.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative '../npm_binary' +require_relative '../shared' + +module PublishChecks + class ExpoAppGenerator + def initialize(template:, registry:) + @template = template + @registry = registry + end + + def generate(app_dir) + Shell.section('Creating a fresh Expo app with Expo CLI') + puts "App dir: #{app_dir}" + puts "Template: #{@template}" + + npm = NpmBinary.find_unwrapped(override: ENV['CREATE_EXPO_APP_NPM_BIN']) + raise 'create-expo-app needs npm to fetch templates, but no npm binary was found. Install npm or set CREATE_EXPO_APP_NPM_BIN=/path/to/npm.' unless npm + + puts "create-expo-app npm binary: #{npm}" + env = { + 'PATH' => "#{File.dirname(npm)}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}", + 'npm_config_registry' => @registry, + 'NPM_CONFIG_REGISTRY' => @registry + } + + Shell.run( + 'pnpm', 'dlx', 'create-expo-app@latest', app_dir, + '--template', @template, + '--no-install', + '--no-agents-md', + env: env + ) + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/app_generators/react_native_app_generator.rb b/platforms/react-native/scripts/publish_checks/support/app_generators/react_native_app_generator.rb new file mode 100644 index 00000000..3bdcd50e --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/app_generators/react_native_app_generator.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative '../npm_binary' +require_relative '../shared' + +module PublishChecks + class ReactNativeAppGenerator + APP_NAME = 'CheckoutKitRnSmokeApp' + PACKAGE_NAME = 'com.shopify.checkoutkitrnsmoke' + + def initialize(version:, template:, registry:) + @version = version + @template = template + @registry = registry + end + + def generate(app_dir) + Shell.section('Creating a fresh React Native app with React Native CLI') + puts "App dir: #{app_dir}" + puts "React Native version: #{@version || '(CLI default)'}" + puts "Template: #{@template || '(React Native default)'}" + + npm = NpmBinary.find_unwrapped(override: ENV['CREATE_REACT_NATIVE_APP_NPM_BIN']) + raise 'React Native CLI needs npm to fetch templates, but no npm binary was found. Install npm or set CREATE_REACT_NATIVE_APP_NPM_BIN=/path/to/npm.' unless npm + + puts "React Native CLI npm binary: #{npm}" + command = [ + 'pnpm', 'dlx', ENV.fetch('REACT_NATIVE_CLI_PACKAGE', '@react-native-community/cli@latest'), + 'init', APP_NAME, + '--directory', app_dir, + '--skip-install', + '--skip-git-init', + '--install-pods', 'false', + '--package-name', PACKAGE_NAME, + '--pm', 'npm' + ] + command.concat(['--version', @version]) if @version && !@version.empty? + command.concat(['--template', @template]) if @template && !@template.empty? + + env = { + 'PATH' => "#{File.dirname(npm)}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}", + 'npm_config_registry' => @registry, + 'NPM_CONFIG_REGISTRY' => @registry + } + + Shell.run(*command, env: env) + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/checkout_kit_packager.rb b/platforms/react-native/scripts/publish_checks/support/checkout_kit_packager.rb new file mode 100644 index 00000000..c1c2d43c --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/checkout_kit_packager.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +require_relative 'shared' + +module PublishChecks + class CheckoutKitPackager + attr_reader :react_native_root, :pack_dir + + def initialize(react_native_root:, pack_dir:) + @react_native_root = File.expand_path(react_native_root) + @pack_dir = File.expand_path(pack_dir) + @module_dir = File.join(@react_native_root, 'modules/@shopify/checkout-kit-react-native') + @module_readme = File.join(@module_dir, 'README.md') + @root_readme = File.join(@react_native_root, 'README.md') + end + + def pack(dry_run: false) + Shell.require_command!('pnpm', 'pnpm is required to build and pack the workspace') + Shell.require_command!('node', 'node is required') + Shell.require_command!('tar', 'tar is required') + + Shell.section('Packing @shopify/checkout-kit-react-native') + FileUtils.rm_rf(pack_dir) + FileUtils.mkdir_p(pack_dir) + Shell.detail("Pack directory: #{pack_dir}") + + with_module_readme do + Shell.run('pnpm', 'module', 'clean', chdir: react_native_root) + Shell.run('pnpm', 'module', 'build', chdir: react_native_root) + Shell.run('pnpm', 'pack', '--dry-run', chdir: @module_dir) if dry_run + Shell.run('pnpm', 'pack', '--pack-destination', pack_dir, chdir: @module_dir) + end + + tarballs = Dir[File.join(pack_dir, '*.tgz')] + raise "Expected exactly one tarball in #{pack_dir}, found #{tarballs.length}" unless tarballs.length == 1 + + tarball = File.expand_path(tarballs.first) + Shell.detail("Tarball: #{tarball}") + tarball + end + + private + + def with_module_readme + backup = nil + had_readme = File.exist?(@module_readme) + + begin + backup = File.join(Dir.tmpdir, "checkout-kit-rn-readme.#{$PROCESS_ID}.#{rand(1_000_000)}") + FileUtils.cp(@module_readme, backup) if had_readme + FileUtils.cp(@root_readme, @module_readme) + yield + ensure + if backup && File.exist?(backup) + if had_readme + FileUtils.cp(backup, @module_readme) + else + FileUtils.rm_f(@module_readme) + end + FileUtils.rm_f(backup) + elsif !had_readme + FileUtils.rm_f(@module_readme) + end + end + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/generated_app.rb b/platforms/react-native/scripts/publish_checks/support/generated_app.rb new file mode 100644 index 00000000..2041b519 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/generated_app.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +require_relative 'temp_workspace' + +module PublishChecks + class GeneratedApp + attr_reader :path + + def self.create(app_dir:, app_name:, keep_on_success:, replace_existing: false, temp_namespace: []) + if app_dir && !app_dir.empty? + new( + path: File.expand_path(app_dir), + temp_parent: nil, + explicit: true, + keep_on_success: true, + replace_existing: replace_existing + ) + else + temp_parent = TempWorkspace.mktmpdir( + :apps, + "#{app_name.downcase.gsub(/[^a-z0-9]+/, '-')}-", + namespace: temp_namespace + ) + new( + path: File.join(temp_parent, app_name), + temp_parent: temp_parent, + explicit: false, + keep_on_success: keep_on_success, + replace_existing: false + ) + end + end + + def initialize(path:, temp_parent:, explicit:, keep_on_success:, replace_existing:) + @path = path + @temp_parent = temp_parent + @explicit = explicit + @keep_on_success = keep_on_success + @replace_existing = replace_existing + end + + def prepare! + if File.exist?(path) + if @replace_existing + FileUtils.rm_rf(path) + elsif directory_empty?(path) + FileUtils.rmdir(path) + else + raise "#{path} already exists and is not empty. Choose a different APP_DIR or pass --replace-app-dir." + end + end + + FileUtils.mkdir_p(File.dirname(path)) + end + + def cleanup(success:) + if success && !@keep_on_success && !@explicit && @temp_parent && File.directory?(@temp_parent) + FileUtils.rm_rf(@temp_parent) + return + end + + puts "Generated app preserved at: #{path}" if File.directory?(path) + end + + private + + def directory_empty?(candidate) + File.directory?(candidate) && (Dir.children(candidate) - %w[.DS_Store]).empty? + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/native_build.rb b/platforms/react-native/scripts/publish_checks/support/native_build.rb new file mode 100644 index 00000000..42758d03 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/native_build.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative 'shared' + +module PublishChecks + class NativeBuild + def initialize(app_dir:, platform:, ios_configuration:, ios_destination:, android_gradle_task:) + @app_dir = app_dir + @platform = platform + @ios_configuration = ios_configuration + @ios_destination = ios_destination + @android_gradle_task = android_gradle_task + end + + def build + case @platform + when 'ios' + build_ios + when 'android' + build_android + else + raise "PLATFORM must be ios or android, got #{@platform.inspect}" + end + end + + private + + def build_ios + Shell.require_command!('xcodebuild', 'xcodebuild is required for PLATFORM=ios native builds') + + workspaces = Dir[File.join(@app_dir, 'ios/*.xcworkspace')] + raise "Expected an iOS workspace under #{@app_dir}/ios" if workspaces.empty? + + workspace = workspaces.first + scheme = ENV.fetch('IOS_SCHEME', File.basename(workspace, '.xcworkspace')) + + Shell.section('Compiling iOS app with xcodebuild') + puts "Workspace: #{workspace}" + puts "Scheme: #{scheme}" + puts "Configuration: #{@ios_configuration}" + puts "Destination: #{@ios_destination}" + + Shell.run( + 'xcodebuild', 'build', + '-workspace', workspace, + '-scheme', scheme, + '-configuration', @ios_configuration, + '-sdk', 'iphonesimulator', + '-destination', @ios_destination, + '-derivedDataPath', File.join(@app_dir, 'ios/build/DerivedData'), + '-skipPackagePluginValidation', + 'CODE_SIGNING_ALLOWED=NO', + 'COMPILER_INDEX_STORE_ENABLE=NO', + chdir: @app_dir, + env: {'RCT_NO_LAUNCH_PACKAGER' => '1'} + ) + end + + def build_android + gradlew = File.join(@app_dir, 'android/gradlew') + raise "Expected executable Android Gradle wrapper at #{gradlew}" unless File.executable?(gradlew) + + Shell.section('Compiling Android app with Gradle') + puts "Task: #{@android_gradle_task}" + Shell.run('./gradlew', @android_gradle_task, '--no-daemon', '--console=plain', chdir: File.join(@app_dir, 'android')) + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/npm_binary.rb b/platforms/react-native/scripts/publish_checks/support/npm_binary.rb new file mode 100644 index 00000000..74d37978 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/npm_binary.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module PublishChecks + module NpmBinary + module_function + + def find_unwrapped(override: nil) + return override unless override.nil? || override.empty? + + candidates = `which -a npm 2>/dev/null`.split("\n") + candidates.each do |candidate| + real_candidate = File.realpath(candidate) rescue candidate + next if candidate.include?('package-manager-wrappers') || real_candidate.include?('package-manager-wrappers') + next if candidate.include?('npm-wrapper') || real_candidate.include?('npm-wrapper') + next unless File.executable?(candidate) + + return candidate + end + + nil + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/package_manager.rb b/platforms/react-native/scripts/publish_checks/support/package_manager.rb new file mode 100644 index 00000000..37503d68 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/package_manager.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'digest' +require 'fileutils' + +require_relative 'shared' + +module PublishChecks + class PackageManager + attr_reader :name, :registry + + SUPPORTED = %w[pnpm npm yarn bun].freeze + + def initialize(name:, registry:) + @name = name.to_s + @registry = registry + raise "Unsupported package manager '#{@name}'. Use one of: #{SUPPORTED.join(', ')}" unless SUPPORTED.include?(@name) + end + + def binary + case name + when 'pnpm' then ENV.fetch('PNPM_BIN', 'pnpm') + when 'npm' then ENV.fetch('NPM_BIN', 'npm') + when 'yarn' then ENV.fetch('YARN_BIN', 'yarn') + when 'bun' then ENV.fetch('BUN_BIN', 'bun') + end + end + + def ensure_available! + Shell.require_command!(binary, "#{name} binary '#{binary}' was not found. Install it or set #{name.upcase}_BIN.") + end + + def namespace + @namespace ||= [name, sanitize(version || 'unknown'), binary_fingerprint].join('-') + end + + def version + @version ||= Shell.capture(binary, '--version').strip.lines.first&.strip + rescue StandardError + nil + end + + def resolved_binary + Shell.capture('command', '-v', binary).strip + rescue StandardError + binary + end + + def write_config(app_dir) + FileUtils.mkdir_p(File.join(app_dir, '.home')) + FileUtils.mkdir_p(File.join(app_dir, '.xdg')) + + File.write(File.join(app_dir, '.npmrc'), <<~NPMRC) + node-linker=hoisted + registry=#{registry} + @shopify:registry=#{registry} + ignore-scripts=true + strict-peer-dependencies=false + audit=false + fund=false + NPMRC + + File.write(File.join(app_dir, 'global-npmrc'), <<~NPMRC) + registry=#{registry} + @shopify:registry=#{registry} + audit=false + fund=false + NPMRC + + File.write(File.join(app_dir, '.yarnrc'), <<~YARNRC) + registry "#{registry}" + "@shopify:registry" "#{registry}" + YARNRC + end + + def install(app_dir) + ensure_available! + Shell.section("Installing generated app dependencies with #{name}") + Shell.detail("Binary: #{resolved_binary}") + + case name + when 'pnpm' + run_in_app(app_dir, binary, 'install', '--ignore-scripts') + when 'npm' + run_in_app(app_dir, binary, 'install', '--ignore-scripts', '--no-audit', '--no-fund', '--registry', registry) + when 'yarn' + run_in_app(app_dir, binary, 'install', '--ignore-scripts', '--non-interactive', '--registry', registry) + when 'bun' + run_in_app(app_dir, binary, 'install', '--ignore-scripts', '--registry', registry) + end + end + + def add(app_dir, package_spec) + ensure_available! + Shell.section("Installing package with #{name}") + Shell.detail("Package: #{package_spec}") + + case name + when 'pnpm' + run_in_app(app_dir, binary, 'add', package_spec, '--ignore-scripts') + when 'npm' + run_in_app(app_dir, binary, 'install', package_spec, '--ignore-scripts', '--no-audit', '--no-fund', '--registry', registry) + when 'yarn' + run_in_app(app_dir, binary, 'add', package_spec, '--ignore-scripts', '--non-interactive', '--registry', registry) + when 'bun' + run_in_app(app_dir, binary, 'add', package_spec, '--ignore-scripts', '--registry', registry) + end + end + + def run_script(app_dir, script) + case name + when 'pnpm' + run_in_app(app_dir, binary, script) + when 'npm' + run_in_app(app_dir, binary, 'run', script) + when 'yarn' + run_in_app(app_dir, binary, script) + when 'bun' + run_in_app(app_dir, binary, 'run', script) + end + end + + def exec(app_dir, *command) + case name + when 'pnpm' + run_in_app(app_dir, binary, 'exec', *command) + when 'npm' + run_in_app(app_dir, binary, 'exec', '--', *command) + when 'yarn' + run_in_app(app_dir, binary, *command) + when 'bun' + run_in_app(app_dir, ENV.fetch('BUNX_BIN', 'bunx'), *command) + end + end + + def isolated_env(app_dir) + { + 'HOME' => File.join(app_dir, '.home'), + 'XDG_CONFIG_HOME' => File.join(app_dir, '.xdg'), + 'NPM_CONFIG_USERCONFIG' => File.join(app_dir, '.npmrc'), + 'NPM_CONFIG_GLOBALCONFIG' => File.join(app_dir, 'global-npmrc'), + 'NPM_CONFIG_REGISTRY' => registry, + 'NPM_CONFIG_IGNORE_SCRIPTS' => 'true', + 'NPM_CONFIG_AUDIT' => 'false', + 'NPM_CONFIG_FUND' => 'false', + 'npm_config_registry' => nil, + 'npm_config_userconfig' => nil, + 'npm_config_globalconfig' => nil, + 'YARN_REGISTRY' => nil, + 'BUN_CONFIG_REGISTRY' => nil + } + end + + private + + def run_in_app(app_dir, *command) + Shell.run(*command, chdir: app_dir, env: isolated_env(app_dir)) + end + + def binary_fingerprint + Digest::SHA256.hexdigest(resolved_binary)[0, 8] + end + + def sanitize(value) + value.to_s.gsub(/[^A-Za-z0-9._-]+/, '-').gsub(/\A-+|-+\z/, '') + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/package_verifier.rb b/platforms/react-native/scripts/publish_checks/support/package_verifier.rb new file mode 100644 index 00000000..11efe24a --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/package_verifier.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative 'shared' + +module PublishChecks + module PackageVerifier + module_function + + def verify_installed_tarball(app_dir) + Shell.section('Checking installed package layout') + Shell.run('node', '-e', node_script, app_dir, chdir: app_dir, display: 'node ') + end + + def node_script + <<~'JS' + const fs = require('fs'); + const path = require('path'); + + const appDir = process.argv[1]; + const rnPackageRoot = fs.realpathSync( + path.join(appDir, 'node_modules/@shopify/checkout-kit-react-native'), + ); + const protocolManifest = path.join( + rnPackageRoot, + 'node_modules/@shopify/checkout-kit-protocol/package.json', + ); + if (!fs.existsSync(protocolManifest)) { + throw new Error(`Bundled protocol package missing at ${protocolManifest}`); + } + const resolved = fs.realpathSync( + require.resolve('@shopify/checkout-kit-protocol', { + paths: [path.join(rnPackageRoot, 'lib/module')], + }), + ); + const expectedPrefix = fs.realpathSync(path.join(rnPackageRoot, 'node_modules')); + if (!resolved.startsWith(expectedPrefix)) { + throw new Error(`Protocol resolved outside bundled package: ${resolved}`); + } + console.log(`Installed package root: ${rnPackageRoot}`); + console.log(`Bundled protocol manifest: ${protocolManifest}`); + console.log(`Runtime resolver from RN package finds: ${resolved}`); + JS + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/packed_tarball.rb b/platforms/react-native/scripts/publish_checks/support/packed_tarball.rb new file mode 100644 index 00000000..e5c1fd06 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/packed_tarball.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'json' + +require_relative 'shared' + +module PublishChecks + class PackedTarball + PACKAGE_NAME = '@shopify/checkout-kit-react-native' + PROTOCOL_PACKAGE = '@shopify/checkout-kit-protocol' + DEPENDENCY_SECTIONS = %w[dependencies devDependencies peerDependencies optionalDependencies].freeze + LOCAL_SPEC_PATTERN = /\A(workspace|file|link|portal):/ + + attr_reader :path, :contents, :manifest + + def initialize(path) + @path = File.expand_path(path) + @contents = Shell.capture('tar', '-tzf', @path).split("\n").sort + @manifest = JSON.parse(Shell.capture('tar', '-xOf', @path, 'package/package.json')) + end + + def print_contents + Shell.section('Tarball contents') + puts contents + end + + def print_packed_manifest_summary + Shell.section('Packed package manifest dependency summary') + puts JSON.pretty_generate(manifest_summary(manifest, include_optional: true)) + end + + def validate_manifest! + Shell.section('Checking packed manifest for local/workspace dependency specs') + + local_dependencies = dependency_entries.select do |entry| + entry[:version].is_a?(String) && entry[:version].match?(LOCAL_SPEC_PATTERN) + end + unless local_dependencies.empty? + formatted = local_dependencies.map { |entry| "#{entry[:section]}.#{entry[:name]}=#{entry[:version]}" }.join(', ') + raise "Packed package still contains local/workspace dependencies: #{formatted}" + end + + protocol_dependencies = dependency_entries.select { |entry| entry[:name] == PROTOCOL_PACKAGE } + if protocol_dependencies.any? && !bundled_dependencies.include?(PROTOCOL_PACKAGE) + formatted = protocol_dependencies.map { |entry| "#{entry[:section]}.#{entry[:name]}=#{entry[:version]}" }.join(', ') + raise "Packed package references unpublished #{PROTOCOL_PACKAGE} but does not bundle it: #{formatted}" + end + + puts 'Packed package manifest has no workspace:, file:, link:, or portal: dependency specs.' + if protocol_dependencies.any? + puts "Packed package references #{PROTOCOL_PACKAGE} and marks it as a bundled dependency." + else + puts "Packed package does not depend on #{PROTOCOL_PACKAGE}." + end + + if contents.include?("package/node_modules/#{PROTOCOL_PACKAGE}/package.json") + puts "Tarball includes bundled #{PROTOCOL_PACKAGE} package contents." + elsif protocol_dependencies.any? + raise "Packed manifest references #{PROTOCOL_PACKAGE}, but the tarball does not include it under package/node_modules." + end + end + + def references_protocol_package? + dependency_entries.any? { |entry| entry[:name] == PROTOCOL_PACKAGE } + end + + def manifest_summary(pkg, include_optional: false) + summary = { + name: pkg['name'], + version: pkg['version'], + dependencies: pkg['dependencies'], + devDependencies: pkg['devDependencies'], + peerDependencies: pkg['peerDependencies'], + bundledDependencies: pkg['bundledDependencies'] || pkg['bundleDependencies'] + } + summary[:optionalDependencies] = pkg['optionalDependencies'] if include_optional && pkg.key?('optionalDependencies') + summary.compact + end + + private + + def dependency_entries + @dependency_entries ||= DEPENDENCY_SECTIONS.flat_map do |section| + (manifest[section] || {}).map do |name, version| + {section: section, name: name, version: version} + end + end + end + + def bundled_dependencies + @bundled_dependencies ||= Array(manifest['bundledDependencies']) + Array(manifest['bundleDependencies']) + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/shared.rb b/platforms/react-native/scripts/publish_checks/support/shared.rb new file mode 100644 index 00000000..83243b96 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/shared.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Re-exports the shared Checkout Kit CLI helpers under the PublishChecks +# namespace, so existing unqualified references (Shell, Env, Dotenv, ...) +# resolve to the shared implementations in scripts/lib/checkout_kit/cli. + +lib = File.expand_path('../../../../../scripts/lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +require 'checkout_kit/cli' + +module PublishChecks + Shell = CheckoutKit::CLI::Shell + Dotenv = CheckoutKit::CLI::Dotenv + Env = CheckoutKit::CLI::Env + Redaction = CheckoutKit::CLI::Redaction + ConfigSummary = CheckoutKit::CLI::ConfigSummary + JsonFile = CheckoutKit::CLI::JsonFile +end diff --git a/platforms/react-native/scripts/publish_checks/support/temp_workspace.rb b/platforms/react-native/scripts/publish_checks/support/temp_workspace.rb new file mode 100644 index 00000000..4e63e8e3 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/temp_workspace.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +module PublishChecks + module TempWorkspace + DEFAULT_ROOT_NAME = 'ck-rn-release' + + module_function + + def root + File.expand_path(ENV.fetch('VALIDATE_RELEASE_TMP_DIR', File.join('/tmp', DEFAULT_ROOT_NAME))) + end + + def apps_dir + File.join(root, 'apps') + end + + def installs_dir + File.join(root, 'installs') + end + + def logs_dir + File.join(root, 'logs') + end + + def default_pack_dir(*namespace) + File.join(root, 'pack', *namespace.compact.map(&:to_s)) + end + + def mktmpdir(kind, prefix, namespace: []) + parent = case kind + when :apps then apps_dir + when :installs then installs_dir + when :logs then logs_dir + else File.join(root, kind.to_s) + end + parent = File.join(parent, *Array(namespace).compact.map(&:to_s)) + FileUtils.mkdir_p(parent) + Dir.mktmpdir(prefix, parent) + end + + def clean!(dry_run: false) + return false unless File.exist?(root) + + if dry_run + puts "Would remove: #{root}" + else + FileUtils.rm_rf(root) + puts "Removed: #{root}" + end + true + end + end +end diff --git a/platforms/react-native/scripts/publish_checks/support/templates/expo/App.tsx b/platforms/react-native/scripts/publish_checks/support/templates/expo/App.tsx new file mode 100644 index 00000000..114fdb81 --- /dev/null +++ b/platforms/react-native/scripts/publish_checks/support/templates/expo/App.tsx @@ -0,0 +1,219 @@ +/* eslint-disable eslint-comments/no-unlimited-disable */ +// @ts-nocheck +/* eslint-disable */ + +import React, {useCallback, useMemo, useState} from 'react'; +import { + ActivityIndicator, + Alert, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { + CheckoutProtocol, + ShopifyCheckoutProvider, + useShopifyCheckout, +} from '@shopify/checkout-kit-react-native'; +import type {ProtocolHandlers} from '@shopify/checkout-kit-react-native'; + +const smokeConfig = require('./smoke.config.json') as { + storefrontDomain: string; + storefrontAccessToken: string; + storefrontVersion: string; + checkoutUrl: string; +}; + +const STOREFRONT_DOMAIN = smokeConfig.storefrontDomain; +const STOREFRONT_ACCESS_TOKEN = smokeConfig.storefrontAccessToken; +const STOREFRONT_VERSION = smokeConfig.storefrontVersion; +const HARDCODED_CHECKOUT_URL = smokeConfig.checkoutUrl; + +function appendLog(setLogs: React.Dispatch>, message: string) { + const line = `${new Date().toISOString()} ${message}`; + console.log(line); + setLogs(previous => [line, ...previous].slice(0, 40)); +} + +async function storefrontGraphql(query: string, variables: Record = {}) { + if (!STOREFRONT_DOMAIN || !STOREFRONT_ACCESS_TOKEN) { + throw new Error('Set STOREFRONT_DOMAIN and STOREFRONT_ACCESS_TOKEN, or set CHECKOUT_URL, before launching checkout.'); + } + + const response = await fetch( + `https://${STOREFRONT_DOMAIN}/api/${STOREFRONT_VERSION}/graphql.json`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Storefront-Access-Token': STOREFRONT_ACCESS_TOKEN, + }, + body: JSON.stringify({query, variables}), + }, + ); + + const json = await response.json(); + if (!response.ok || json.errors?.length) { + throw new Error(JSON.stringify(json.errors ?? json, null, 2)); + } + return json.data; +} + +async function createCheckoutUrl() { + if (HARDCODED_CHECKOUT_URL.length > 0) { + return HARDCODED_CHECKOUT_URL; + } + + const productData = await storefrontGraphql(` + query FirstAvailableVariant { + products(first: 10) { + edges { + node { + title + variants(first: 10) { + edges { + node { + id + title + availableForSale + } + } + } + } + } + } + } + `); + + const variant = productData.products.edges + .flatMap((edge: any) => edge.node.variants.edges.map((variantEdge: any) => ({ + productTitle: edge.node.title, + ...variantEdge.node, + }))) + .find((node: any) => node.availableForSale); + + if (!variant?.id) { + throw new Error(`No available variants found for ${STOREFRONT_DOMAIN}`); + } + + const cartData = await storefrontGraphql( + `mutation CreateCheckoutKitSmokeCart($lines: [CartLineInput!]!) { + cartCreate(input: {lines: $lines}) { + cart { + id + checkoutUrl + totalQuantity + } + userErrors { + field + message + } + } + }`, + {lines: [{merchandiseId: variant.id, quantity: 1}]}, + ); + + const errors = cartData.cartCreate.userErrors; + if (errors?.length) { + throw new Error(JSON.stringify(errors, null, 2)); + } + + const checkoutUrl = cartData.cartCreate.cart?.checkoutUrl; + if (!checkoutUrl) { + throw new Error('cartCreate did not return checkoutUrl'); + } + + return checkoutUrl; +} + +function SmokeScreen() { + const checkout = useShopifyCheckout(); + const [logs, setLogs] = useState([]); + const [busy, setBusy] = useState(false); + const protocolSummary = useMemo( + () => Object.entries(CheckoutProtocol).map(([name, method]) => `${name}: ${method}`).join('\n'), + [], + ); + + const launchCheckout = useCallback(async () => { + setBusy(true); + try { + appendLog(setLogs, `CheckoutProtocol.start = ${CheckoutProtocol.start}`); + appendLog(setLogs, 'Creating checkout URL'); + const checkoutUrl = await createCheckoutUrl(); + appendLog(setLogs, `Presenting checkout: ${checkoutUrl}`); + + const protocolHandlers: ProtocolHandlers = { + [CheckoutProtocol.start]: payload => appendLog(setLogs, `protocol start: ${JSON.stringify(payload).slice(0, 300)}`), + [CheckoutProtocol.complete]: payload => appendLog(setLogs, `protocol complete: ${JSON.stringify(payload).slice(0, 300)}`), + [CheckoutProtocol.error]: payload => appendLog(setLogs, `protocol error: ${JSON.stringify(payload).slice(0, 300)}`), + [CheckoutProtocol.lineItemsChange]: payload => appendLog(setLogs, `protocol lineItemsChange: ${JSON.stringify(payload).slice(0, 300)}`), + [CheckoutProtocol.messagesChange]: payload => appendLog(setLogs, `protocol messagesChange: ${JSON.stringify(payload).slice(0, 300)}`), + [CheckoutProtocol.totalsChange]: payload => appendLog(setLogs, `protocol totalsChange: ${JSON.stringify(payload).slice(0, 300)}`), + }; + + checkout.present( + checkoutUrl, + { + onClose: () => appendLog(setLogs, 'checkout closed'), + onFail: error => appendLog(setLogs, `checkout failed: ${error.message}`), + }, + protocolHandlers, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appendLog(setLogs, `ERROR: ${message}`); + Alert.alert('Checkout smoke error', message); + } finally { + setBusy(false); + } + }, [checkout]); + + return ( + + + Checkout Kit Expo Tarball Smoke + This app was created with Expo CLI, installed the packed tarball, and imports CheckoutProtocol through the installed package. + + + Protocol methods resolved at runtime + {protocolSummary} + + + + {busy ? : Create cart + launch checkout} + + + Logs + {logs.map((line, index) => ( + {line} + ))} + + + ); +} + +export default function App() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: {flex: 1, backgroundColor: '#0b1020'}, + content: {gap: 16, padding: 20}, + title: {color: 'white', fontSize: 24, fontWeight: '700'}, + body: {color: '#cbd5e1', fontSize: 16, lineHeight: 22}, + card: {backgroundColor: '#111827', borderRadius: 12, padding: 16}, + label: {color: '#93c5fd', fontSize: 14, fontWeight: '700', marginBottom: 8}, + mono: {color: '#e5e7eb', fontFamily: 'Courier', fontSize: 13, lineHeight: 20}, + button: {alignItems: 'center', backgroundColor: '#2563eb', borderRadius: 12, padding: 16}, + buttonDisabled: {opacity: 0.6}, + buttonText: {color: 'white', fontSize: 16, fontWeight: '700'}, + log: {color: '#d1d5db', fontFamily: 'Courier', fontSize: 12, marginBottom: 6}, +}); diff --git a/platforms/react-native/scripts/validate_release b/platforms/react-native/scripts/validate_release new file mode 100755 index 00000000..b05a3374 --- /dev/null +++ b/platforms/react-native/scripts/validate_release @@ -0,0 +1,68 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative 'publish_checks/commands/clean' +require_relative 'publish_checks/commands/expo_app' +require_relative 'publish_checks/commands/package_check' +require_relative 'publish_checks/commands/react_native_app' + +script_dir = File.expand_path(__dir__) +react_native_root = File.expand_path('..', script_dir) + +PublishChecks::Shell.verbose = true if ARGV.delete('--verbose') +# Route shared Shell logs into the release-validation workspace. +PublishChecks::Shell.log_dir = PublishChecks::TempWorkspace.logs_dir +command = ARGV.shift + +begin + case command + when 'clean' + PublishChecks::Commands::Clean.run(ARGV, react_native_root: react_native_root) + when 'package', 'npm-package' + PublishChecks::Commands::PackageCheck.run(ARGV, react_native_root: react_native_root) + when 'expo' + PublishChecks::Commands::ExpoApp.run(ARGV, react_native_root: react_native_root) + when 'react-native', 'rn' + PublishChecks::Commands::ReactNativeApp.run(ARGV, react_native_root: react_native_root) + when nil, '-h', '--help' + puts <<~HELP + Usage: #{File.basename($PROGRAM_NAME)} [--verbose] [options] + + Commands: + clean Remove generated apps, install fixtures, logs, and default pack output + package Validate the packed npm package tarball and clean installs + react-native, rn Validate a generated React Native app against the packed tarball + expo Validate a generated Expo app against the packed tarball + + Examples: + #{File.basename($PROGRAM_NAME)} clean + #{File.basename($PROGRAM_NAME)} package + #{File.basename($PROGRAM_NAME)} package --package-managers pnpm,npm + #{File.basename($PROGRAM_NAME)} react-native --version 0.80.2 + #{File.basename($PROGRAM_NAME)} react-native --version 0.85.0-rc.0 --platform android + #{File.basename($PROGRAM_NAME)} expo --template expo-template-blank-typescript@sdk-55 + #{File.basename($PROGRAM_NAME)} expo --platform ios --skip-native-build + + Shared options: + --verbose prints resolved configuration and streams stdout from package managers, app generators, and build tools. + + Shared environment defaults: + VALIDATE_RELEASE_TMP_DIR, PACK_DIR, KEEP_TMP, INSTALL_REGISTRY, VERBOSE + Package manager binaries: PNPM_BIN, NPM_BIN, YARN_BIN, BUN_BIN, BUNX_BIN + Generated app commands also support APP_DIR, PACKAGE_MANAGER, PLATFORM + App generators also support CREATE_EXPO_APP_NPM_BIN, CREATE_REACT_NATIVE_APP_NPM_BIN, REACT_NATIVE_CLI_PACKAGE + Expo launch config reads STOREFRONT_DOMAIN, STOREFRONT_ACCESS_TOKEN, CHECKOUT_URL, and STOREFRONT_VERSION/API_VERSION from env, root .env, or sample/.env + HELP + exit(command.nil? ? 1 : 0) + else + warn "Unknown command: #{command}" + warn "Run #{File.basename($PROGRAM_NAME)} --help for usage." + exit 1 + end +rescue Interrupt + warn "\nInterrupted." + exit 130 +rescue StandardError => e + warn "\nERROR: #{e.message}" + exit 1 +end diff --git a/platforms/react-native/tsconfig.json b/platforms/react-native/tsconfig.json index 8a05ab81..34af2eaa 100644 --- a/platforms/react-native/tsconfig.json +++ b/platforms/react-native/tsconfig.json @@ -21,5 +21,6 @@ "strict": true, "verbatimModuleSyntax": true, "target": "esnext" - } + }, + "exclude": ["scripts/publish_checks/support/templates/**/*"] }