From 7451bc1a7460b46f3aba59049ba7482d509eefcd Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Mon, 19 Oct 2026 11:02:15 -0600 Subject: [PATCH] ci(expo): fix Dependabot alignment and cover the appium app - Sort the Apply artifact check with LC_ALL=C so it can match - Align and validate appium-test-environment alongside example --- .github/workflows/apply-expo-dependencies.yml | 63 ++++++++----- .github/workflows/expo-dependencies.yml | 7 +- scripts/expo-dependencies.js | 92 +++++++++++-------- scripts/validate-expo-alignment-patch.js | 70 ++++++++------ 4 files changed, 142 insertions(+), 90 deletions(-) diff --git a/.github/workflows/apply-expo-dependencies.yml b/.github/workflows/apply-expo-dependencies.yml index 3ec9da4..c7dace6 100644 --- a/.github/workflows/apply-expo-dependencies.yml +++ b/.github/workflows/apply-expo-dependencies.yml @@ -56,17 +56,28 @@ jobs: [[ "$EVENT_HEAD_BRANCH" =~ ^dependabot/npm_and_yarn/(expo-compatible-patches|expo-sdk)-[A-Za-z0-9._/-]+$ ]] git check-ref-format "refs/heads/$EVENT_HEAD_BRANCH" - expected_entries=( - 'example|d' - 'example/app.json|f' - 'example/package.json|f' - 'metadata.json|f' - 'package.json|f' + # Sort both lists with the same collation: '/' sorts before '|' in + # the C locale, so a hand-ordered list would never match. + mapfile -t expected_entries < <( + printf '%s\n' \ + 'appium-test-environment|d' \ + 'appium-test-environment/app.json|f' \ + 'appium-test-environment/package.json|f' \ + 'example|d' \ + 'example/app.json|f' \ + 'example/package.json|f' \ + 'metadata.json|f' \ + 'package.json|f' | + LC_ALL=C sort ) mapfile -t actual_entries < <( - find "$INCOMING" -mindepth 1 -printf '%P|%y\n' | sort + find "$INCOMING" -mindepth 1 -printf '%P|%y\n' | LC_ALL=C sort ) - [[ "${actual_entries[*]}" == "${expected_entries[*]}" ]] + if [[ "${actual_entries[*]}" != "${expected_entries[*]}" ]]; then + echo "Unexpected alignment artifact contents:" >&2 + printf ' %s\n' "${actual_entries[@]}" >&2 + exit 1 + fi metadata="$INCOMING/metadata.json" jq -e \ @@ -109,7 +120,10 @@ jobs: ((${#pr_files[@]} > 0)) for file in "${pr_files[@]}"; do case "$file" in - package.json|example/package.json|example/app.json|yarn.lock) ;; + package.json|yarn.lock) ;; + example/package.json|example/app.json) ;; + appium-test-environment/package.json) ;; + appium-test-environment/app.json) ;; *) echo "Unexpected file in Expo Dependabot PR: $file" >&2 exit 1 @@ -158,17 +172,25 @@ jobs: cp "$INCOMING/package.json" package.json cp "$INCOMING/example/package.json" example/package.json cp "$INCOMING/example/app.json" example/app.json + cp "$INCOMING/appium-test-environment/package.json" \ + appium-test-environment/package.json + cp "$INCOMING/appium-test-environment/app.json" \ + appium-test-environment/app.json - if git diff --quiet HEAD -- \ - package.json example/package.json example/app.json; then + manifests=( + package.json + example/package.json + example/app.json + appium-test-environment/package.json + appium-test-environment/app.json + ) + + if git diff --quiet HEAD -- "${manifests[@]}"; then echo 'has_changes=false' >> "$GITHUB_OUTPUT" exit 0 fi - git add -- \ - package.json \ - example/package.json \ - example/app.json + git add -- "${manifests[@]}" node "$validator" "$GITHUB_WORKSPACE" # The checked-in Yarn binary regenerates the lockfile without running @@ -184,7 +206,10 @@ jobs: ) for file in "${changed_files[@]}"; do case "$file" in - package.json|example/package.json|example/app.json|yarn.lock) ;; + package.json|yarn.lock) ;; + example/package.json|example/app.json) ;; + appium-test-environment/package.json) ;; + appium-test-environment/app.json) ;; *) echo "Lockfile generation changed an unexpected file: $file" >&2 exit 1 @@ -192,11 +217,7 @@ jobs: esac done - git add -- \ - package.json \ - example/package.json \ - example/app.json \ - yarn.lock + git add -- "${manifests[@]}" yarn.lock node "$validator" "$GITHUB_WORKSPACE" git diff --cached --check HEAD -- echo 'has_changes=true' >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/expo-dependencies.yml b/.github/workflows/expo-dependencies.yml index 5c2b6fc..32b1745 100644 --- a/.github/workflows/expo-dependencies.yml +++ b/.github/workflows/expo-dependencies.yml @@ -8,6 +8,8 @@ on: - 'package.json' - 'example/package.json' - 'example/app.json' + - 'appium-test-environment/package.json' + - 'appium-test-environment/app.json' - 'yarn.lock' permissions: contents: read @@ -62,9 +64,12 @@ jobs: run: | set -euo pipefail output="$RUNNER_TEMP/expo-alignment" - mkdir -p "$output/example" + mkdir -p "$output/example" "$output/appium-test-environment" cp package.json "$output/" cp example/package.json example/app.json "$output/example/" + cp appium-test-environment/package.json \ + appium-test-environment/app.json \ + "$output/appium-test-environment/" jq -n \ --argjson pr_number "$PR_NUMBER" \ diff --git a/scripts/expo-dependencies.js b/scripts/expo-dependencies.js index 510e206..3371fe0 100644 --- a/scripts/expo-dependencies.js +++ b/scripts/expo-dependencies.js @@ -4,34 +4,43 @@ const { join } = require('node:path'); const ROOT_DIRECTORY = join(__dirname, '..'); const ROOT_PACKAGE_PATH = join(ROOT_DIRECTORY, 'package.json'); -const EXAMPLE_PACKAGE_PATH = join(ROOT_DIRECTORY, 'example', 'package.json'); +// Every Expo app workspace must stay on the Expo SDK cohort. The example app +// is the source of truth for the root's shared development dependencies. +const APP_WORKSPACES = [ + { name: 'example', manifest: 'example/package.json' }, + { + name: 'TransactAppiumTestEnvironment', + manifest: 'appium-test-environment/package.json', + }, +]; +const [EXAMPLE_WORKSPACE] = APP_WORKSPACES; const SHARED_DEPENDENCIES = [ { name: 'react', rootSection: 'devDependencies', - exampleSection: 'dependencies', + appSection: 'dependencies', }, { name: 'react-native', rootSection: 'devDependencies', - exampleSection: 'dependencies', + appSection: 'dependencies', }, { name: '@react-native/babel-preset', - exampleName: 'react-native', + appName: 'react-native', rootSection: 'devDependencies', - exampleSection: 'dependencies', + appSection: 'dependencies', }, { name: '@types/react', rootSection: 'devDependencies', - exampleSection: 'devDependencies', + appSection: 'devDependencies', }, { name: 'typescript', rootSection: 'devDependencies', - exampleSection: 'devDependencies', + appSection: 'devDependencies', }, ]; @@ -51,68 +60,73 @@ function dependencyVersion(specifier, dependency, manifest) { return match[1]; } -function sharedDependencyState(rootPackage, examplePackage) { +function sharedDependencyState(rootPackage, workspace, appPackage) { return SHARED_DEPENDENCIES.map((dependency) => { - const exampleName = dependency.exampleName ?? dependency.name; + const appName = dependency.appName ?? dependency.name; const rootSpecifier = rootPackage[dependency.rootSection]?.[dependency.name]; - const exampleSpecifier = - examplePackage[dependency.exampleSection]?.[exampleName]; + const appSpecifier = appPackage[dependency.appSection]?.[appName]; - if (!rootSpecifier || !exampleSpecifier) { + if (!rootSpecifier || !appSpecifier) { throw new Error( - `${dependency.name} must exist in the root and ${exampleName} must exist in the example manifest` + `${dependency.name} must exist in the root and ${appName} must exist in ${workspace.manifest}` ); } return { ...dependency, rootSpecifier, - exampleSpecifier, + appSpecifier, rootVersion: dependencyVersion( rootSpecifier, dependency.name, 'package.json' ), - exampleVersion: dependencyVersion( - exampleSpecifier, - exampleName, - 'example/package.json' - ), + appVersion: dependencyVersion(appSpecifier, appName, workspace.manifest), }; }); } -function checkSharedDependencies(rootPackage, examplePackage) { - const mismatches = sharedDependencyState(rootPackage, examplePackage).filter( - ({ rootVersion, exampleVersion }) => rootVersion !== exampleVersion +function checkSharedDependencies(rootPackage) { + const mismatches = APP_WORKSPACES.flatMap((workspace) => + sharedDependencyState( + rootPackage, + workspace, + readPackage(join(ROOT_DIRECTORY, workspace.manifest)) + ) + .filter(({ rootVersion, appVersion }) => rootVersion !== appVersion) + .map((dependency) => ({ ...dependency, workspace })) ); if (mismatches.length > 0) { const details = mismatches .map( - ({ name, rootSpecifier, exampleSpecifier }) => - ` - ${name}: root ${rootSpecifier}, example ${exampleSpecifier}` + ({ name, rootSpecifier, appSpecifier, workspace }) => + ` - ${name}: root ${rootSpecifier}, ${workspace.manifest} ${appSpecifier}` ) .join('\n'); throw new Error(`Shared Expo dependencies are not aligned:\n${details}`); } - console.log('Shared root and example dependencies are aligned'); + console.log('Shared root and app workspace dependencies are aligned'); } function syncSharedDependencies(rootPackage, examplePackage) { const updates = []; - for (const dependency of sharedDependencyState(rootPackage, examplePackage)) { - if (dependency.rootVersion === dependency.exampleVersion) { + for (const dependency of sharedDependencyState( + rootPackage, + EXAMPLE_WORKSPACE, + examplePackage + )) { + if (dependency.rootVersion === dependency.appVersion) { continue; } const rangePrefix = dependency.rootSpecifier.match(/^[~^]/)?.[0] ?? ''; rootPackage[dependency.rootSection][dependency.name] = - `${rangePrefix}${dependency.exampleVersion}`; + `${rangePrefix}${dependency.appVersion}`; updates.push(dependency.name); } @@ -146,28 +160,28 @@ function main() { const mode = process.argv[2]; if (mode === 'check') { - runYarn(['workspace', 'example', 'expo', 'install', '--check']); - checkSharedDependencies( - readPackage(ROOT_PACKAGE_PATH), - readPackage(EXAMPLE_PACKAGE_PATH) - ); + for (const workspace of APP_WORKSPACES) { + runYarn(['workspace', workspace.name, 'expo', 'install', '--check']); + } + checkSharedDependencies(readPackage(ROOT_PACKAGE_PATH)); return; } if (mode === 'fix') { - runYarn(['workspace', 'example', 'expo', 'install', '--fix']); + for (const workspace of APP_WORKSPACES) { + runYarn(['workspace', workspace.name, 'expo', 'install', '--fix']); + } const rootPackage = readPackage(ROOT_PACKAGE_PATH); - const examplePackage = readPackage(EXAMPLE_PACKAGE_PATH); + const examplePackage = readPackage( + join(ROOT_DIRECTORY, EXAMPLE_WORKSPACE.manifest) + ); syncSharedDependencies(rootPackage, examplePackage); // This command intentionally updates the root manifest, so CI's default // immutable install mode must be disabled while regenerating the lockfile. runYarn(['install', '--no-immutable']); - checkSharedDependencies( - readPackage(ROOT_PACKAGE_PATH), - readPackage(EXAMPLE_PACKAGE_PATH) - ); + checkSharedDependencies(readPackage(ROOT_PACKAGE_PATH)); return; } diff --git a/scripts/validate-expo-alignment-patch.js b/scripts/validate-expo-alignment-patch.js index 0cb38dd..5944e5f 100644 --- a/scripts/validate-expo-alignment-patch.js +++ b/scripts/validate-expo-alignment-patch.js @@ -3,10 +3,13 @@ const { readFileSync } = require('node:fs'); const { join, resolve } = require('node:path'); const { isDeepStrictEqual } = require('node:util'); +const APP_DIRECTORIES = ['example', 'appium-test-environment']; const ALLOWED_FILES = new Set([ 'package.json', - 'example/package.json', - 'example/app.json', + ...APP_DIRECTORIES.flatMap((directory) => [ + `${directory}/package.json`, + `${directory}/app.json`, + ]), 'yarn.lock', ]); const ROOT_DEPENDENCIES = new Set([ @@ -16,7 +19,7 @@ const ROOT_DEPENDENCIES = new Set([ '@types/react', 'typescript', ]); -const EXAMPLE_DEPENDENCIES = new Set([ +const APP_DEPENDENCIES = new Set([ '@react-native-async-storage/async-storage', '@types/react', 'eslint-config-expo', @@ -110,7 +113,7 @@ function isExpoDependency(name) { name === 'expo' || name.startsWith('expo-') || name.startsWith('@expo/') || - EXAMPLE_DEPENDENCIES.has(name) + APP_DEPENDENCIES.has(name) ); } @@ -141,27 +144,27 @@ function validateManifest(file, before, after, allowed) { } } -function validateAppConfig(before, after, examplePackage) { +function validateAppConfig(file, before, after, appPackage) { if (JSON.stringify(before) === JSON.stringify(after)) return; const oldPlugins = before.expo?.plugins; const newPlugins = after.expo?.plugins; if (!Array.isArray(oldPlugins) || !Array.isArray(newPlugins)) { - fail('example/app.json may only add Expo plugins'); + fail(`${file} may only add Expo plugins`); } const additions = newPlugins.slice(oldPlugins.length); const declared = { - ...examplePackage.dependencies, - ...examplePackage.devDependencies, + ...appPackage.dependencies, + ...appPackage.devDependencies, }; const seen = new Set( oldPlugins.map((plugin) => (Array.isArray(plugin) ? plugin[0] : plugin)) ); if (additions.length === 0) { - fail('example/app.json contains unsafe Expo plugin changes'); + fail(`${file} contains unsafe Expo plugin changes`); } for (const plugin of additions) { @@ -171,7 +174,7 @@ function validateAppConfig(before, after, examplePackage) { !Object.hasOwn(declared, plugin) || seen.has(plugin) ) { - fail('example/app.json contains unsafe Expo plugin changes'); + fail(`${file} contains unsafe Expo plugin changes`); } seen.add(plugin); @@ -180,7 +183,7 @@ function validateAppConfig(before, after, examplePackage) { const expected = JSON.parse(JSON.stringify(before)); expected.expo.plugins = [...oldPlugins, ...additions]; if (!isDeepStrictEqual(expected, after)) { - fail('example/app.json contains changes outside Expo plugin additions'); + fail(`${file} contains changes outside Expo plugin additions`); } } @@ -192,22 +195,22 @@ function dependencyVersion(specifier, name) { return specifier.replace(/^[~^]/, ''); } -function validateSharedDependencies(rootPackage, examplePackage) { +function validateSharedDependencies(rootPackage, file, appPackage) { for (const [ name, rootSection, - exampleSection, - exampleName = name, + appSection, + appName = name, ] of SHARED_DEPENDENCIES) { const rootSpecifier = rootPackage[rootSection]?.[name]; - const exampleSpecifier = examplePackage[exampleSection]?.[exampleName]; + const appSpecifier = appPackage[appSection]?.[appName]; if ( dependencyVersion(rootSpecifier, name) !== - dependencyVersion(exampleSpecifier, name) + dependencyVersion(appSpecifier, name) ) { fail( - `${name} is not aligned: root ${rootSpecifier}, example ${exampleSpecifier}` + `${name} is not aligned: root ${rootSpecifier}, ${file} ${appSpecifier}` ); } } @@ -236,10 +239,6 @@ function main() { const staged = stagedFiles(directory); const rootBefore = readJson(directory, 'package.json', true); const rootAfter = readJson(directory, 'package.json'); - const exampleBefore = readJson(directory, 'example/package.json', true); - const exampleAfter = readJson(directory, 'example/package.json'); - const appBefore = readJson(directory, 'example/app.json', true); - const appAfter = readJson(directory, 'example/app.json'); validateManifest( 'package.json', @@ -248,14 +247,27 @@ function main() { (section, name) => section === 'devDependencies' && ROOT_DEPENDENCIES.has(name) ); - validateManifest( - 'example/package.json', - exampleBefore, - exampleAfter, - (_section, name) => isExpoDependency(name) - ); - validateAppConfig(appBefore, appAfter, exampleAfter); - validateSharedDependencies(rootAfter, exampleAfter); + + for (const appDirectory of APP_DIRECTORIES) { + const manifest = `${appDirectory}/package.json`; + const appConfig = `${appDirectory}/app.json`; + const packageAfter = readJson(directory, manifest); + + validateManifest( + manifest, + readJson(directory, manifest, true), + packageAfter, + (_section, name) => isExpoDependency(name) + ); + validateAppConfig( + appConfig, + readJson(directory, appConfig, true), + readJson(directory, appConfig), + packageAfter + ); + validateSharedDependencies(rootAfter, manifest, packageAfter); + } + validateLockfile(directory, staged); console.log(`Validated Expo alignment changes: ${[...staged].join(', ')}`);