expo-release 2.0.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ import * as Notifications from 'expo-notifications';
2
+ /** Async key-value store. AsyncStorage and expo-secure-store adapters fit. */
3
+ export interface KeyValueStorage {
4
+ getItem(key: string): Promise<string | null>;
5
+ setItem(key: string, value: string): Promise<void>;
6
+ }
7
+ export interface PushNotificationsOptions {
8
+ /** Remembers that the one-time prompt ran. */
9
+ storage: KeyValueStorage;
10
+ /** Saves this device's Expo push token on your backend; `null` removes it. */
11
+ registerDevice: (token: string | null) => Promise<void>;
12
+ /**
13
+ * Explains push before the OS dialog. Resolve `true` to continue. Without
14
+ * it, `prompt()` goes straight to the OS dialog.
15
+ */
16
+ confirm?: () => Promise<boolean>;
17
+ /** Push is off: the app runs on a simulator or the user denied permission. */
18
+ onUnavailable?: (reason: 'not-a-device' | 'denied') => void;
19
+ onError?: (message: string, error: unknown) => void;
20
+ /** Defaults to what expo-notifications reads from the app config. */
21
+ projectId?: string;
22
+ /** Android channel `default`, needed for heads-up notifications. */
23
+ androidChannel?: Notifications.NotificationChannelInput;
24
+ /** How notifications show while the app is in the foreground. */
25
+ foregroundBehavior?: Notifications.NotificationBehavior;
26
+ storageKey?: string;
27
+ }
28
+ export interface NotificationListeners {
29
+ onReceived?: (notification: Notifications.Notification) => void;
30
+ /** Also called with the response that launched the app, if any. */
31
+ onResponse?: (response: Notifications.NotificationResponse) => void;
32
+ }
33
+ export type PushNotifications = ReturnType<typeof createPushNotifications>;
34
+ /**
35
+ * expo-notifications behind one object: permission flow, Expo push token and
36
+ * device registration. Create it once at module level.
37
+ */
38
+ export declare function createPushNotifications(options: PushNotificationsOptions): {
39
+ getPermissionStatus: () => Promise<Notifications.NotificationPermissionsStatus>;
40
+ hasPrompted: () => Promise<boolean>;
41
+ requestPermissions: () => Promise<boolean>;
42
+ getPushToken: () => Promise<string | null>;
43
+ syncToken: () => Promise<boolean>;
44
+ /**
45
+ * Asks for push once per install (after sign-in, say), then registers the
46
+ * token. Never throws; concurrent calls share one dialog.
47
+ */
48
+ prompt(): Promise<boolean>;
49
+ /** Turns push on from settings. Resolves whether it is on. */
50
+ enable(): Promise<boolean>;
51
+ /** Removes this device's token from your backend. */
52
+ disable(): Promise<void>;
53
+ /** Subscribes to notifications. Returns the unsubscribe function. */
54
+ listen({ onReceived, onResponse }: NotificationListeners): () => void;
55
+ };
56
+ /**
57
+ * Push state for a settings screen. `enabled` is the user's saved preference;
58
+ * while it is on, the token is re-registered on mount.
59
+ */
60
+ export declare function usePushNotifications(push: PushNotifications, { enabled }?: {
61
+ enabled?: boolean;
62
+ }): {
63
+ permission: Notifications.NotificationPermissionsStatus | null;
64
+ isEnabled: boolean;
65
+ isLoading: boolean;
66
+ prompt: () => Promise<boolean>;
67
+ enable: () => Promise<boolean>;
68
+ disable: () => Promise<void>;
69
+ refresh: () => Promise<void>;
70
+ };
@@ -0,0 +1,8 @@
1
+ import type { ReleasePlatform } from './args';
2
+ export declare const SOURCE_MAP_OUTPUT_DIR = "dist";
3
+ export declare function expoCommand(...args: string[]): string[];
4
+ export declare function sourceMapExportCommand(platform: ReleasePlatform): string[];
5
+ export declare function sourceMapUpdateArgs(): string[];
6
+ export declare function mergeUpdateArgs(extra: string[] | undefined, exportSourceMaps: boolean): string[];
7
+ export declare function defaultHasSourceMaps(directory?: string): Promise<boolean>;
8
+ export declare function assertSourceMaps(hasSourceMaps?: () => Promise<boolean>): Promise<void>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "expo-release",
3
- "version": "2.0.0",
4
- "description": "Fingerprint-driven Expo EAS release: OTA update or native store build",
3
+ "version": "2.2.1",
4
+ "description": "Fingerprint-driven Expo EAS release: OTA update or native store build, plus app runtime helpers",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
7
7
  "main": "./dist/index.js",
@@ -39,25 +39,62 @@
39
39
  "bun": "./src/index.ts",
40
40
  "import": "./dist/index.js",
41
41
  "default": "./dist/index.js"
42
+ },
43
+ "./runtime": {
44
+ "types": "./dist/runtime/index.d.ts",
45
+ "import": "./dist/runtime/index.js",
46
+ "default": "./dist/runtime/index.js"
42
47
  }
43
48
  },
44
49
  "scripts": {
45
50
  "build": "bun run build:js && bun run build:types",
46
- "build:js": "bun build ./src/index.ts ./src/cli.ts --outdir ./dist --target bun --packages external",
51
+ "build:js": "bun build ./src/index.ts ./src/cli.ts --outdir ./dist --target bun --packages external && bun build ./src/runtime/index.ts --outdir ./dist/runtime --target browser --packages external",
47
52
  "build:types": "tsgo -p tsconfig.build.json",
48
53
  "prepublishOnly": "bun run build",
49
- "publish:patch": "npm version patch && npm publish",
50
- "publish:minor": "npm version minor && npm publish",
51
- "publish:major": "npm version major && npm publish",
52
54
  "test": "bun test",
53
55
  "lint": "bun run typecheck && bun biome check",
54
56
  "lint:write": "bun run typecheck && bun biome check --write",
55
- "typecheck": "tsgo --noEmit"
57
+ "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.runtime.json",
58
+ "verify:package": "node scripts/verify-package.mjs"
59
+ },
60
+ "dependencies": {
61
+ "hanzio": "^3.1.0",
62
+ "zod": "^4.3.6"
63
+ },
64
+ "peerDependencies": {
65
+ "expo-device": ">=55",
66
+ "expo-notifications": ">=55",
67
+ "expo-updates": ">=55",
68
+ "react": ">=19"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "expo-device": {
72
+ "optional": true
73
+ },
74
+ "expo-notifications": {
75
+ "optional": true
76
+ },
77
+ "expo-updates": {
78
+ "optional": true
79
+ },
80
+ "react": {
81
+ "optional": true
82
+ }
56
83
  },
57
84
  "devDependencies": {
58
85
  "@biomejs/biome": "^2.4.6",
59
86
  "@types/bun": "latest",
87
+ "@types/react": "~19.2.0",
60
88
  "@typescript/native-preview": "^7.0.0-dev.20260306.1",
89
+ "expo-device": "~55.0.10",
90
+ "expo-notifications": "~55.0.14",
91
+ "expo-updates": "~55.0.16",
92
+ "react": "19.2.0",
61
93
  "typescript": "^5.7.0"
62
- }
94
+ },
95
+ "homepage": "https://github.com/HansKristoffer/expo-release#readme",
96
+ "bugs": {
97
+ "url": "https://github.com/HansKristoffer/expo-release/issues"
98
+ },
99
+ "packageManager": "bun@1.4.2"
63
100
  }
package/src/args.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { parseArgs } from 'node:util'
1
+ import type { CommandContext } from 'hanzio/cli'
2
+ import { z } from 'zod'
2
3
 
3
4
  export const DEFAULT_IMPACT_PATHS = ['apps/expo/'] as const
4
5
 
@@ -31,6 +32,7 @@ export type ReleaseOptions = {
31
32
  preUpdate?: string
32
33
  postUpdate?: string
33
34
  updateExtraArgs?: string
35
+ exportSourceMaps: boolean
34
36
  group?: string
35
37
  runtimeVersion?: string
36
38
  message?: string
@@ -38,76 +40,133 @@ export type ReleaseOptions = {
38
40
  dryRun: boolean
39
41
  }
40
42
 
41
- export const USAGE =
42
- 'Usage: expo-release <release|decide|operate> --profile <profile> [--base <sha>] [--head <sha>] [--platform ios|android] [--impact-paths apps/expo/] [--operation deploy|force-native|retry-ota|republish|rollback-embedded] [--deploy-type none|ota|native] [--channel <channel>] [--environment <env>] [--group <id>] [--runtime-version <hash>] [--message <msg>] [--fingerprint <hash>] [--auto-submit true] [--pre-update <cmd>] [--post-update <cmd>] [--update-extra-args <args>] [--dry-run] [--ignore-expo-impact true]'
43
-
44
- function fromEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
45
- const value = env[name]?.trim()
46
- return value ? value : undefined
47
- }
48
-
49
- function firstDefined(
50
- ...values: Array<string | undefined>
51
- ): string | undefined {
52
- return values.find((value) => value !== undefined && value.length > 0)
53
- }
54
-
55
- export function optionalString(value: string | undefined): string | undefined {
56
- const trimmed = value?.trim()
57
- return trimmed ? trimmed : undefined
58
- }
59
-
60
- export function parseImpactPaths(value: string | undefined): string[] {
61
- const paths = (value ?? DEFAULT_IMPACT_PATHS.join(','))
62
- .split(',')
63
- .map((path) => path.trim())
64
- .filter(Boolean)
65
- if (paths.length === 0) {
66
- throw new Error('At least one --impact-paths entry is required')
67
- }
68
- return paths
69
- }
70
-
71
- export function parsePlatform(value: string | undefined): ReleasePlatform {
72
- if (value === undefined || value === 'ios') return 'ios'
73
- if (value === 'android') return 'android'
74
- throw new Error('Usage: --platform ios|android')
75
- }
76
-
77
- export function parseBoolean(value: string | undefined, fallback: boolean) {
78
- if (value === undefined) return fallback
79
- if (value === 'true') return true
80
- if (value === 'false') return false
81
- throw new Error(`Expected true or false, received: ${value}`)
82
- }
83
-
84
- export function parseOperation(value: string | undefined): Operation {
85
- if (
86
- value === undefined ||
87
- value === 'deploy' ||
88
- value === 'force-native' ||
89
- value === 'retry-ota' ||
90
- value === 'republish' ||
91
- value === 'rollback-embedded'
92
- ) {
93
- return value ?? 'deploy'
94
- }
95
- throw new Error(
96
- 'Usage: --operation deploy|force-native|retry-ota|republish|rollback-embedded'
97
- )
98
- }
99
-
100
- export function parseDeployType(value: string | undefined): DeployType {
101
- if (value === undefined || value === 'none') return 'none'
102
- if (value === 'ota' || value === 'native') return value
103
- throw new Error('Usage: --deploy-type none|ota|native')
104
- }
105
-
106
- export function parseCommandName(value: string | undefined): CommandName {
107
- if (value === 'release' || value === 'decide' || value === 'operate') {
108
- return value
43
+ // Every flag falls back to an EXPO_RELEASE_* variable: the composite action passes its inputs that way.
44
+ const text = (env: string) =>
45
+ z
46
+ .string()
47
+ .transform((value) => value.trim() || undefined)
48
+ .optional()
49
+ .meta({ env: `EXPO_RELEASE_${env}` })
50
+
51
+ // `--auto-submit false` takes a value, so these are not boolean switches.
52
+ const trueFalse = (env: string, fallback: 'true' | 'false') =>
53
+ z
54
+ .enum(['true', 'false'])
55
+ .default(fallback)
56
+ .transform((value) => value === 'true')
57
+ .meta({ env: `EXPO_RELEASE_${env}` })
58
+
59
+ export const releaseFlags = z
60
+ .object({
61
+ profile: z
62
+ .string()
63
+ .trim()
64
+ .min(1)
65
+ .meta({ env: 'EXPO_RELEASE_PROFILE' })
66
+ .describe('EAS build profile, also the default update channel'),
67
+ base: text('BASE').describe(
68
+ 'Git base SHA for the impact diff (falls back to GITHUB_EVENT_BEFORE)'
69
+ ),
70
+ head: text('HEAD').describe(
71
+ 'Git head SHA for the impact diff (falls back to GITHUB_SHA)'
72
+ ),
73
+ platform: z
74
+ .enum(['ios', 'android'])
75
+ .default('ios')
76
+ .meta({ env: 'EXPO_RELEASE_PLATFORM' })
77
+ .describe('Native platform'),
78
+ impactPaths: z
79
+ .string()
80
+ .default(DEFAULT_IMPACT_PATHS.join(','))
81
+ .transform((value, ctx) => {
82
+ const paths = value
83
+ .split(',')
84
+ .map((path) => path.trim())
85
+ .filter(Boolean)
86
+ if (paths.length === 0)
87
+ ctx.addIssue({
88
+ code: 'custom',
89
+ message: 'At least one --impact-paths entry is required'
90
+ })
91
+ return paths
92
+ })
93
+ .meta({ env: 'EXPO_RELEASE_IMPACT_PATHS' })
94
+ .describe('Comma-separated repo-root paths that count as an Expo change'),
95
+ ignoreExpoImpact: trueFalse('IGNORE_EXPO_IMPACT', 'false').describe(
96
+ 'Check the fingerprint even when no impact path changed'
97
+ ),
98
+ operation: z
99
+ .enum([
100
+ 'deploy',
101
+ 'force-native',
102
+ 'retry-ota',
103
+ 'republish',
104
+ 'rollback-embedded'
105
+ ])
106
+ .default('deploy')
107
+ .meta({ env: 'EXPO_RELEASE_OPERATION' })
108
+ .describe('What to run'),
109
+ deployType: z
110
+ .enum(['none', 'ota', 'native'])
111
+ .default('none')
112
+ .meta({ env: 'EXPO_RELEASE_DEPLOY_TYPE' })
113
+ .describe('Decision type for operate'),
114
+ channel: text('CHANNEL').describe(
115
+ 'EAS Update channel (default: the profile)'
116
+ ),
117
+ environment: text('ENVIRONMENT').describe(
118
+ 'EAS environment for eas update (required for Expo SDK 55+)'
119
+ ),
120
+ autoSubmit: trueFalse('AUTO_SUBMIT', 'true').describe(
121
+ 'Pass --auto-submit on native builds'
122
+ ),
123
+ preUpdate: text('PRE_UPDATE').describe(
124
+ 'Shell command run before eas update'
125
+ ),
126
+ postUpdate: text('POST_UPDATE').describe(
127
+ 'Shell command run after a successful eas update'
128
+ ),
129
+ updateExtraArgs: text('UPDATE_EXTRA_ARGS').describe(
130
+ 'Extra tokens appended to eas update'
131
+ ),
132
+ exportSourceMaps: trueFalse('EXPORT_SOURCE_MAPS', 'false').describe(
133
+ 'On OTA, expo export with external source maps, then publish dist'
134
+ ),
135
+ group: text('GROUP').describe('Update group ID for --operation republish'),
136
+ runtimeVersion: text('RUNTIME_VERSION').describe(
137
+ 'Runtime version for rollback-embedded (default: the fingerprint)'
138
+ ),
139
+ message: text('MESSAGE').describe('Override the eas update message'),
140
+ fingerprint: text('FINGERPRINT').describe(
141
+ 'Fingerprint hash to use instead of generating one'
142
+ ),
143
+ dryRun: z
144
+ .boolean()
145
+ .meta({ env: 'EXPO_RELEASE_DRY_RUN' })
146
+ .describe('Print mutating EAS commands without running them')
147
+ })
148
+ .superRefine((flags, ctx) => {
149
+ if (flags.operation === 'republish' && !flags.group)
150
+ ctx.addIssue({
151
+ code: 'custom',
152
+ path: ['group'],
153
+ message: 'required by --operation republish'
154
+ })
155
+ })
156
+
157
+ export type ReleaseFlags = z.output<typeof releaseFlags>
158
+
159
+ export function toReleaseOptions(
160
+ command: CommandName,
161
+ { context: flags, env }: CommandContext<ReleaseFlags>
162
+ ): ReleaseOptions {
163
+ return {
164
+ ...flags,
165
+ command,
166
+ base: flags.base ?? env.GITHUB_EVENT_BEFORE,
167
+ head: flags.head ?? env.GITHUB_SHA,
168
+ channel: flags.channel ?? flags.profile
109
169
  }
110
- throw new Error(USAGE)
111
170
  }
112
171
 
113
172
  export function needsDecide(operation: Operation): boolean {
@@ -136,183 +195,3 @@ export function shouldIgnoreExpoImpact(
136
195
  options.operation === 'deploy'
137
196
  )
138
197
  }
139
-
140
- export function parseReleaseOptions(
141
- args: string[],
142
- env: NodeJS.ProcessEnv = process.env
143
- ): ReleaseOptions {
144
- let values: {
145
- profile?: string
146
- base?: string
147
- head?: string
148
- platform?: string
149
- 'impact-paths'?: string
150
- 'ignore-expo-impact'?: string
151
- operation?: string
152
- 'deploy-type'?: string
153
- channel?: string
154
- environment?: string
155
- 'auto-submit'?: string
156
- 'pre-update'?: string
157
- 'post-update'?: string
158
- 'update-extra-args'?: string
159
- group?: string
160
- 'runtime-version'?: string
161
- message?: string
162
- fingerprint?: string
163
- 'dry-run'?: boolean
164
- }
165
- let positionals: string[]
166
-
167
- try {
168
- const parsed = parseArgs({
169
- args,
170
- allowPositionals: true,
171
- strict: true,
172
- options: {
173
- profile: { type: 'string' },
174
- base: { type: 'string' },
175
- head: { type: 'string' },
176
- platform: { type: 'string' },
177
- 'impact-paths': { type: 'string' },
178
- 'ignore-expo-impact': { type: 'string' },
179
- operation: { type: 'string' },
180
- 'deploy-type': { type: 'string' },
181
- channel: { type: 'string' },
182
- environment: { type: 'string' },
183
- 'auto-submit': { type: 'string' },
184
- 'pre-update': { type: 'string' },
185
- 'post-update': { type: 'string' },
186
- 'update-extra-args': { type: 'string' },
187
- group: { type: 'string' },
188
- 'runtime-version': { type: 'string' },
189
- message: { type: 'string' },
190
- fingerprint: { type: 'string' },
191
- 'dry-run': { type: 'boolean', default: false }
192
- }
193
- })
194
- values = parsed.values
195
- positionals = parsed.positionals
196
- } catch (error) {
197
- const message = error instanceof Error ? error.message : String(error)
198
- throw new Error(`${USAGE}\n${message}`)
199
- }
200
-
201
- if (positionals.length > 1) {
202
- throw new Error(USAGE)
203
- }
204
-
205
- const command = parseCommandName(
206
- positionals[0] ?? fromEnv(env, 'EXPO_RELEASE_COMMAND')
207
- )
208
- const profile = firstDefined(
209
- values.profile,
210
- fromEnv(env, 'EXPO_RELEASE_PROFILE')
211
- )
212
- if (!profile) {
213
- throw new Error(USAGE)
214
- }
215
-
216
- const operation = parseOperation(
217
- firstDefined(values.operation, fromEnv(env, 'EXPO_RELEASE_OPERATION'))
218
- )
219
- const group = optionalString(
220
- firstDefined(values.group, fromEnv(env, 'EXPO_RELEASE_GROUP'))
221
- )
222
- if (operation === 'republish' && !group) {
223
- throw new Error(
224
- 'Usage: --operation republish requires --group <update-group-id>'
225
- )
226
- }
227
-
228
- const channel =
229
- firstDefined(values.channel, fromEnv(env, 'EXPO_RELEASE_CHANNEL')) ??
230
- profile
231
-
232
- return {
233
- command,
234
- profile,
235
- base: optionalString(
236
- firstDefined(
237
- values.base,
238
- fromEnv(env, 'EXPO_RELEASE_BASE'),
239
- fromEnv(env, 'GITHUB_EVENT_BEFORE')
240
- )
241
- ),
242
- head: optionalString(
243
- firstDefined(
244
- values.head,
245
- fromEnv(env, 'EXPO_RELEASE_HEAD'),
246
- fromEnv(env, 'GITHUB_SHA')
247
- )
248
- ),
249
- platform: parsePlatform(
250
- firstDefined(values.platform, fromEnv(env, 'EXPO_RELEASE_PLATFORM'))
251
- ),
252
- impactPaths: parseImpactPaths(
253
- firstDefined(
254
- values['impact-paths'],
255
- fromEnv(env, 'EXPO_RELEASE_IMPACT_PATHS')
256
- )
257
- ),
258
- ignoreExpoImpact: parseBoolean(
259
- firstDefined(
260
- values['ignore-expo-impact'],
261
- fromEnv(env, 'EXPO_RELEASE_IGNORE_EXPO_IMPACT')
262
- ),
263
- false
264
- ),
265
- operation,
266
- deployType: parseDeployType(
267
- firstDefined(
268
- values['deploy-type'],
269
- fromEnv(env, 'EXPO_RELEASE_DEPLOY_TYPE')
270
- )
271
- ),
272
- channel,
273
- environment: optionalString(
274
- firstDefined(values.environment, fromEnv(env, 'EXPO_RELEASE_ENVIRONMENT'))
275
- ),
276
- autoSubmit: parseBoolean(
277
- firstDefined(
278
- values['auto-submit'],
279
- fromEnv(env, 'EXPO_RELEASE_AUTO_SUBMIT')
280
- ),
281
- true
282
- ),
283
- preUpdate: optionalString(
284
- firstDefined(
285
- values['pre-update'],
286
- fromEnv(env, 'EXPO_RELEASE_PRE_UPDATE')
287
- )
288
- ),
289
- postUpdate: optionalString(
290
- firstDefined(
291
- values['post-update'],
292
- fromEnv(env, 'EXPO_RELEASE_POST_UPDATE')
293
- )
294
- ),
295
- updateExtraArgs: optionalString(
296
- firstDefined(
297
- values['update-extra-args'],
298
- fromEnv(env, 'EXPO_RELEASE_UPDATE_EXTRA_ARGS')
299
- )
300
- ),
301
- group,
302
- runtimeVersion: optionalString(
303
- firstDefined(
304
- values['runtime-version'],
305
- fromEnv(env, 'EXPO_RELEASE_RUNTIME_VERSION')
306
- )
307
- ),
308
- message: optionalString(
309
- firstDefined(values.message, fromEnv(env, 'EXPO_RELEASE_MESSAGE'))
310
- ),
311
- fingerprint: optionalString(
312
- firstDefined(values.fingerprint, fromEnv(env, 'EXPO_RELEASE_FINGERPRINT'))
313
- ),
314
- dryRun:
315
- values['dry-run'] === true ||
316
- parseBoolean(fromEnv(env, 'EXPO_RELEASE_DRY_RUN'), false)
317
- }
318
- }