@expo/build-tools 21.0.2 → 21.0.3

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.
@@ -41,7 +41,18 @@ async function androidBuilder(ctx) {
41
41
  }
42
42
  }
43
43
  async function buildAsync(ctx) {
44
- await (0, setup_1.setupAsync)(ctx);
44
+ const jobHooksRef = { current: null };
45
+ try {
46
+ await buildInnerAsync(ctx, jobHooksRef);
47
+ }
48
+ finally {
49
+ // Native builds have no other drain path for the hook context's queued
50
+ // step-metric uploads.
51
+ await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
52
+ }
53
+ }
54
+ async function buildInnerAsync(ctx, jobHooksRef) {
55
+ await (0, setup_1.setupAsync)(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
45
56
  const evictUsedBefore = new Date();
46
57
  const workingDirectory = ctx.getReactNativeProjectDirectory();
47
58
  const hasNativeCode = ctx.job.type === eas_build_job_1.Workflow.GENERIC;
@@ -50,7 +50,19 @@ async function iosBuilder(ctx) {
50
50
  }
51
51
  }
52
52
  async function buildAsync(ctx) {
53
- await (0, setup_1.setupAsync)(ctx);
53
+ const jobHooksRef = { current: null };
54
+ try {
55
+ await buildInnerAsync(ctx, jobHooksRef);
56
+ }
57
+ finally {
58
+ // Native builds have no other drain path for the hook context's queued
59
+ // step-metric uploads. Must wrap the WHOLE body — iOS's existing inner
60
+ // try/finally only starts after setupAsync returns.
61
+ await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
62
+ }
63
+ }
64
+ async function buildInnerAsync(ctx, jobHooksRef) {
65
+ await (0, setup_1.setupAsync)(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
54
66
  const hasNativeCode = ctx.job.type === eas_build_job_1.Workflow.GENERIC;
55
67
  const evictUsedBefore = new Date();
56
68
  const credentialsManager = new manager_1.default(ctx);
@@ -111,6 +111,9 @@ function validateEasBuildInternalResult({ oldJob, result, }) {
111
111
  // We want to retain values that we have set on the job.
112
112
  appId: oldJob.appId,
113
113
  initiatingUserId: oldJob.initiatingUserId,
114
+ // Hooks are resolved server-side at submit; the mid-build eas.json
115
+ // regeneration must not add, drop, or change them (the original wins).
116
+ hooks: oldJob.hooks,
114
117
  ...(oldJob.platform === eas_build_job_1.Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
115
118
  ? { refreshAdHocProvisioningProfile: true }
116
119
  : null),
@@ -0,0 +1,18 @@
1
+ import { BuildJob, BuildPhase, HookAnchorId } from '@expo/eas-build-job';
2
+ import { BuildContext } from '../context';
3
+ import { ParsedJobHooks } from './jobHooks';
4
+ /**
5
+ * Runs a native build phase with its `before_<anchor>` / `after_<anchor>` hooks
6
+ * wrapped around it: before-entries -> phase (in a try) -> after-entries, with
7
+ * after-on-failure guaranteed. A failing before sequence skips the phase AND the
8
+ * after-hooks (steps-engine parity). The phase error always wins over an
9
+ * after-hook error. Passthrough (no hooks or no keys for this anchor) runs the
10
+ * phase exactly once with zero overhead.
11
+ */
12
+ export declare function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({ ctx, hooks, buildPhase, anchor, fn, }: {
13
+ ctx: BuildContext<TJob>;
14
+ hooks: ParsedJobHooks | null;
15
+ buildPhase: BuildPhase;
16
+ anchor: HookAnchorId;
17
+ fn: () => Promise<T>;
18
+ }): Promise<T>;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runHookableBuildPhaseAsync = runHookableBuildPhaseAsync;
4
+ const eas_build_job_1 = require("@expo/eas-build-job");
5
+ const steps_1 = require("@expo/steps");
6
+ /**
7
+ * Runs a native build phase with its `before_<anchor>` / `after_<anchor>` hooks
8
+ * wrapped around it: before-entries -> phase (in a try) -> after-entries, with
9
+ * after-on-failure guaranteed. A failing before sequence skips the phase AND the
10
+ * after-hooks (steps-engine parity). The phase error always wins over an
11
+ * after-hook error. Passthrough (no hooks or no keys for this anchor) runs the
12
+ * phase exactly once with zero overhead.
13
+ */
14
+ async function runHookableBuildPhaseAsync({ ctx, hooks, buildPhase, anchor, fn, }) {
15
+ // runHookEntriesAsync no-ops when the key has no entries.
16
+ if (hooks) {
17
+ await runHookEntriesAsync({ ctx, hooks, timing: 'before', anchor });
18
+ }
19
+ let result;
20
+ try {
21
+ result = await ctx.runBuildPhase(buildPhase, fn);
22
+ }
23
+ catch (phaseError) {
24
+ if (hooks) {
25
+ try {
26
+ await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
27
+ }
28
+ catch (hookError) {
29
+ // The phase error wins; still surface the after-hook failure in the log.
30
+ ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
31
+ }
32
+ }
33
+ throw phaseError;
34
+ }
35
+ if (hooks) {
36
+ await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: false });
37
+ }
38
+ return result;
39
+ }
40
+ async function runHookEntriesAsync({ ctx, hooks, timing, anchor, anchorFailed = false, }) {
41
+ const hookKey = `${timing}_${anchor}`;
42
+ const entries = hooks.hookEntriesByKey[hookKey];
43
+ if (!entries?.length) {
44
+ return;
45
+ }
46
+ // Refresh the hook context from the live BuildContext before running: env
47
+ // moves as the build runs (set-env written back below; profile env), and
48
+ // job/metadata are replaced by eas build:internal.
49
+ hooks.customBuildContext.updateEnv(ctx.env);
50
+ // Overwrite the job/metadata snapshot; NOT updateJobInformation, whose
51
+ // delta-merge would duplicate environmentSecrets on each refresh.
52
+ hooks.customBuildContext.job = ctx.job;
53
+ hooks.customBuildContext.metadata = ctx.metadata;
54
+ if (timing === 'after' && anchorFailed) {
55
+ // Mirror the engine: the anchor's failure is marked on the shared context so
56
+ // `failure()` / `success()` on after-hook steps read the phase outcome.
57
+ hooks.globalContext.markAsFailed();
58
+ }
59
+ const { failedLocally, firstError } = await (0, steps_1.executeHookStepsAsync)(hooks.globalContext, entries, {
60
+ anchor,
61
+ timing,
62
+ ...(timing === 'after' ? { anchorResult: anchorFailed ? 'failed' : 'success' } : null),
63
+ });
64
+ // Reverse write-back BEFORE propagating a failure: env set by the entries that
65
+ // did run must reach the build context even when a later entry failed, so the
66
+ // native phases (and the outer lifecycle hooks) see it. Hook-carrying builds
67
+ // are git-based, so updateEnv's gate does not fire here.
68
+ ctx.updateEnv(hooks.globalContext.env);
69
+ if (failedLocally) {
70
+ throw new eas_build_job_1.UserError(eas_build_job_1.ErrorCode.HOOKS_ERROR, `Hook "${hookKey}" failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Check the failing hook step's logs above.`, { cause: firstError });
71
+ }
72
+ }
@@ -0,0 +1,16 @@
1
+ import { BuildJob, HookAnchorId, HookKey } from '@expo/eas-build-job';
2
+ import { BuildStepGlobalContext, HookEntry } from '@expo/steps';
3
+ import { BuildContext } from '../context';
4
+ import { CustomBuildContext } from '../customBuildContext';
5
+ export interface ParsedJobHooks {
6
+ globalContext: BuildStepGlobalContext;
7
+ customBuildContext: CustomBuildContext;
8
+ hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>>;
9
+ }
10
+ /**
11
+ * Parses the native job's `hooks` into engine-ready entries, once, at setup
12
+ * time. Shape-validates every registered key (reachable or not); constructs
13
+ * entries only for the anchors this build actually wraps. Returns null when the
14
+ * job declares no hooks.
15
+ */
16
+ export declare function parseJobHooksAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>, wrappedAnchors: readonly HookAnchorId[]): Promise<ParsedJobHooks | null>;
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseJobHooksAsync = parseJobHooksAsync;
4
+ const eas_build_job_1 = require("@expo/eas-build-job");
5
+ const steps_1 = require("@expo/steps");
6
+ const customBuildContext_1 = require("../customBuildContext");
7
+ const easFunctionGroups_1 = require("../steps/easFunctionGroups");
8
+ const easFunctions_1 = require("../steps/easFunctions");
9
+ /**
10
+ * Parses the native job's `hooks` into engine-ready entries, once, at setup
11
+ * time. Shape-validates every registered key (reachable or not); constructs
12
+ * entries only for the anchors this build actually wraps. Returns null when the
13
+ * job declares no hooks.
14
+ */
15
+ async function parseJobHooksAsync(ctx, wrappedAnchors) {
16
+ const hooks = ctx.job.hooks;
17
+ if (!hooks || Object.keys(hooks).length === 0) {
18
+ return null;
19
+ }
20
+ const customBuildContext = new customBuildContext_1.CustomBuildContext(ctx);
21
+ const globalContext = new steps_1.BuildStepGlobalContext(customBuildContext, false);
22
+ // Hook steps run in the checked-out project directory, not the pre-checkout
23
+ // target directory the global context defaults to.
24
+ globalContext.markAsCheckedOut(ctx.logger);
25
+ const externalFunctions = (0, easFunctions_1.getEasFunctions)(customBuildContext);
26
+ const externalFunctionGroups = (0, easFunctionGroups_1.getEasFunctionGroups)(customBuildContext);
27
+ // Shape-validate every REGISTERED key, reachable or not (steps-world parity:
28
+ // a malformed `before_submit` fails a steps build, so it fails here too), and
29
+ // warn on keys this build won't run. An unknown key is inert — a key newer
30
+ // than this worker must be ignored, never fail the job.
31
+ for (const [key, steps] of Object.entries(hooks)) {
32
+ const parsed = (0, eas_build_job_1.parseHookKey)(key);
33
+ if (parsed === null) {
34
+ ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
35
+ continue;
36
+ }
37
+ if (Array.isArray(steps) && steps.length === 0) {
38
+ // `[]` is an explicit opt-out; validateSteps requires >= 1 step.
39
+ continue;
40
+ }
41
+ try {
42
+ (0, eas_build_job_1.validateSteps)(steps);
43
+ }
44
+ catch (err) {
45
+ throw new eas_build_job_1.UserError(eas_build_job_1.ErrorCode.HOOKS_ERROR, `Invalid steps in "hooks.${key}": ${err instanceof Error ? err.message : String(err)}`, { cause: err });
46
+ }
47
+ // Registered but not wrapped by this build: shape-checked above, then
48
+ // skipped. Function existence is deliberately NOT checked (that happens only
49
+ // during construction) — an unknown `uses:` under an unwrapped key warns
50
+ // rather than failing the build.
51
+ if (!wrappedAnchors.includes(parsed.anchorId)) {
52
+ ctx.logger.warn(`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`);
53
+ }
54
+ }
55
+ // Construct entries for the wrapped anchors in EXECUTION order (per anchor,
56
+ // before_ then after_) so generated step ids and output references follow the
57
+ // order steps actually run. All entries share one globalContext, so env and
58
+ // outputs accumulate across keys.
59
+ const hookEntriesByKey = {};
60
+ const orderedSteps = [];
61
+ for (const anchor of wrappedAnchors) {
62
+ for (const side of ['before', 'after']) {
63
+ const key = `${side}_${anchor}`;
64
+ const steps = hooks[key];
65
+ if (!Array.isArray(steps) || steps.length === 0) {
66
+ continue;
67
+ }
68
+ let entries;
69
+ try {
70
+ entries = await (0, steps_1.constructHookEntriesAsync)(globalContext, steps, {
71
+ externalFunctions,
72
+ externalFunctionGroups,
73
+ });
74
+ }
75
+ catch (err) {
76
+ throw new eas_build_job_1.UserError(eas_build_job_1.ErrorCode.HOOKS_ERROR, `Failed to parse hooks.${key}: ${err instanceof Error ? err.message : String(err)}. Hooks use the same syntax as workflow steps.`, { cause: err });
77
+ }
78
+ hookEntriesByKey[key] = entries;
79
+ for (const entry of entries) {
80
+ orderedSteps.push(...entry.steps);
81
+ }
82
+ }
83
+ }
84
+ // One aggregate pass over every constructed step in execution order: unique
85
+ // ids, output references, and platform allowance. Per-key validation misses
86
+ // cross-key id collisions and would reject a legal after -> before output
87
+ // reference as a future reference.
88
+ try {
89
+ await (0, steps_1.validateHookStepsAsync)(globalContext, orderedSteps);
90
+ }
91
+ catch (err) {
92
+ throw new eas_build_job_1.UserError(eas_build_job_1.ErrorCode.HOOKS_ERROR, `The job's hooks are invalid: ${err instanceof Error ? err.message : String(err)}. Fix the reported step ids or output references.`, { cause: err });
93
+ }
94
+ return { globalContext, customBuildContext, hookEntriesByKey };
95
+ }
@@ -1,3 +1,15 @@
1
- import { BuildJob } from '@expo/eas-build-job';
1
+ import { BuildJob, HookAnchorId } from '@expo/eas-build-job';
2
+ import { ParsedJobHooks } from './jobHooks';
2
3
  import { BuildContext } from '../context';
3
- export declare function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>): Promise<void>;
4
+ /**
5
+ * Mutable holder the builder passes into setupAsync. setupAsync assigns
6
+ * `current` the moment hooks are parsed (before any hook runs), so the builder's
7
+ * finally can drain queued metric uploads even if setup fails partway through.
8
+ */
9
+ export interface JobHooksRef {
10
+ current: ParsedJobHooks | null;
11
+ }
12
+ export declare function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>, { wrappedAnchors, jobHooksRef, }: {
13
+ wrappedAnchors: readonly HookAnchorId[];
14
+ jobHooksRef: JobHooksRef;
15
+ }): Promise<void>;
@@ -10,7 +10,9 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
10
10
  const nullthrows_1 = __importDefault(require("nullthrows"));
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const easBuildInternal_1 = require("./easBuildInternal");
13
+ const hookableBuildPhase_1 = require("./hookableBuildPhase");
13
14
  const installDependencies_1 = require("./installDependencies");
15
+ const jobHooks_1 = require("./jobHooks");
14
16
  const projectSources_1 = require("./projectSources");
15
17
  const xcodeEnv_1 = require("../ios/xcodeEnv");
16
18
  const hooks_1 = require("../utils/hooks");
@@ -26,7 +28,7 @@ class DoctorTimeoutError extends Error {
26
28
  }
27
29
  class InstallDependenciesTimeoutError extends Error {
28
30
  }
29
- async function setupAsync(ctx) {
31
+ async function setupAsync(ctx, { wrappedAnchors, jobHooksRef, }) {
30
32
  await ctx.runBuildPhase(eas_build_job_1.BuildPhase.PREPARE_PROJECT, async () => {
31
33
  await (0, retry_1.retryAsync)(async () => {
32
34
  await fs_extra_1.default.rm(ctx.buildDirectory, { recursive: true, force: true });
@@ -64,27 +66,42 @@ async function setupAsync(ctx) {
64
66
  ctx.logger.error({ err }, `Failed to read eas.json.`);
65
67
  }
66
68
  });
67
- const packageJson = await ctx.runBuildPhase(eas_build_job_1.BuildPhase.READ_PACKAGE_JSON, async () => {
68
- return (0, project_1.readAndLogPackageJson)(ctx.logger, ctx.getReactNativeProjectDirectory());
69
+ await ctx.runBuildPhase(eas_build_job_1.BuildPhase.READ_PACKAGE_JSON, async () => {
70
+ (0, project_1.readAndLogPackageJson)(ctx.logger, ctx.getReactNativeProjectDirectory());
69
71
  });
70
- await ctx.runBuildPhase(eas_build_job_1.BuildPhase.INSTALL_DEPENDENCIES, async () => {
71
- const expoVersion = ctx.metadata?.sdkVersion ??
72
- (0, packageManager_1.getPackageVersionFromPackageJson)({
73
- packageJson,
74
- packageName: 'expo',
72
+ // Parse hooks unconditionally, after every pre-install env mutation
73
+ // (PREPARE_PROJECT, the git-based profile-env block, PRE_INSTALL_HOOK) and
74
+ // before the first wrapped phase.
75
+ jobHooksRef.current = await (0, jobHooks_1.parseJobHooksAsync)(ctx, wrappedAnchors);
76
+ await (0, hookableBuildPhase_1.runHookableBuildPhaseAsync)({
77
+ ctx,
78
+ hooks: jobHooksRef.current,
79
+ buildPhase: eas_build_job_1.BuildPhase.INSTALL_DEPENDENCIES,
80
+ anchor: 'install_node_modules',
81
+ fn: async () => {
82
+ // Read package.json fresh here rather than reusing the READ_PACKAGE_JSON
83
+ // capture: a before_install_node_modules hook may have patched it, and the
84
+ // steps-world install already reads it fresh. Silent read — READ_PACKAGE_JSON
85
+ // stays the only logging site.
86
+ const freshPackageJson = (0, project_1.readPackageJson)(ctx.getReactNativeProjectDirectory());
87
+ const expoVersion = ctx.metadata?.sdkVersion ??
88
+ (0, packageManager_1.getPackageVersionFromPackageJson)({
89
+ packageJson: freshPackageJson,
90
+ packageName: 'expo',
91
+ });
92
+ const reactNativeVersion = ctx.metadata?.reactNativeVersion ??
93
+ (0, packageManager_1.getPackageVersionFromPackageJson)({
94
+ packageJson: freshPackageJson,
95
+ packageName: 'react-native',
96
+ });
97
+ await runInstallDependenciesAsync(ctx, {
98
+ useFrozenLockfile: (0, packageManager_1.shouldUseFrozenLockfile)({
99
+ env: ctx.env,
100
+ sdkVersion: expoVersion,
101
+ reactNativeVersion,
102
+ }),
75
103
  });
76
- const reactNativeVersion = ctx.metadata?.reactNativeVersion ??
77
- (0, packageManager_1.getPackageVersionFromPackageJson)({
78
- packageJson,
79
- packageName: 'react-native',
80
- });
81
- await runInstallDependenciesAsync(ctx, {
82
- useFrozenLockfile: (0, packageManager_1.shouldUseFrozenLockfile)({
83
- env: ctx.env,
84
- sdkVersion: expoVersion,
85
- reactNativeVersion,
86
- }),
87
- });
104
+ },
88
105
  });
89
106
  await ctx.runBuildPhase(eas_build_job_1.BuildPhase.READ_APP_CONFIG, async () => {
90
107
  const appConfig = await ctx.appConfig;
@@ -111,7 +128,9 @@ async function setupAsync(ctx) {
111
128
  ctx.updateJobInformation(newJob, newMetadata);
112
129
  });
113
130
  }
114
- const hasExpoPackage = !!packageJson.dependencies?.expo;
131
+ // Read fresh: a before_install_node_modules hook may have added or removed
132
+ // the expo dependency.
133
+ const hasExpoPackage = !!(0, project_1.readPackageJson)(ctx.getReactNativeProjectDirectory()).dependencies?.expo;
115
134
  if (!ctx.env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP && hasExpoPackage) {
116
135
  await ctx.runBuildPhase(eas_build_job_1.BuildPhase.RUN_EXPO_DOCTOR, async () => {
117
136
  try {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import * as Builders from './builders';
2
- import GCSLoggerStream from './gcs/LoggerStream';
3
- import { GCS } from './gcs/client';
4
- export { Builders, GCS, GCSLoggerStream };
2
+ import RemoteLoggerStream from './logging/RemoteLoggerStream';
3
+ export { Builders, RemoteLoggerStream };
4
+ export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
5
+ export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
5
6
  export { ArtifactToUpload, Artifacts, BuildContext, BuildContextOptions, CacheManager, LogBuffer, SkipNativeBuildError, } from './context';
6
7
  export { PackageManager } from './utils/packageManager';
7
8
  export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
package/dist/index.js CHANGED
@@ -39,13 +39,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
39
39
  return (mod && mod.__esModule) ? mod : { "default": mod };
40
40
  };
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
- exports.Sentry = exports.Datadog = exports.formatGradleProfileReport = exports.parseGradleProfile = exports.runHookIfPresent = exports.Hook = exports.findAndUploadXcodeBuildLogsAsync = exports.PackageManager = exports.SkipNativeBuildError = exports.BuildContext = exports.GCSLoggerStream = exports.GCS = exports.Builders = void 0;
42
+ exports.Sentry = exports.Datadog = exports.formatGradleProfileReport = exports.parseGradleProfile = exports.runHookIfPresent = exports.Hook = exports.findAndUploadXcodeBuildLogsAsync = exports.PackageManager = exports.SkipNativeBuildError = exports.BuildContext = exports.uploadWithSignedUrl = exports.RemoteLoggerStream = exports.Builders = void 0;
43
43
  const Builders = __importStar(require("./builders"));
44
44
  exports.Builders = Builders;
45
- const LoggerStream_1 = __importDefault(require("./gcs/LoggerStream"));
46
- exports.GCSLoggerStream = LoggerStream_1.default;
47
- const client_1 = require("./gcs/client");
48
- Object.defineProperty(exports, "GCS", { enumerable: true, get: function () { return client_1.GCS; } });
45
+ const RemoteLoggerStream_1 = __importDefault(require("./logging/RemoteLoggerStream"));
46
+ exports.RemoteLoggerStream = RemoteLoggerStream_1.default;
47
+ var uploadWithSignedUrl_1 = require("./storage/uploadWithSignedUrl");
48
+ Object.defineProperty(exports, "uploadWithSignedUrl", { enumerable: true, get: function () { return uploadWithSignedUrl_1.uploadWithSignedUrl; } });
49
49
  var context_1 = require("./context");
50
50
  Object.defineProperty(exports, "BuildContext", { enumerable: true, get: function () { return context_1.BuildContext; } });
51
51
  Object.defineProperty(exports, "SkipNativeBuildError", { enumerable: true, get: function () { return context_1.SkipNativeBuildError; } });
@@ -1,7 +1,7 @@
1
1
  import { bunyan } from '@expo/logger';
2
2
  import { Writable } from 'stream';
3
- import { GCS } from './client';
4
- declare class GCSLoggerStream extends Writable {
3
+ import { type SignedUrl } from '../storage/uploadWithSignedUrl';
4
+ declare class RemoteLoggerStream extends Writable {
5
5
  writable: boolean;
6
6
  private readonly logger;
7
7
  private readonly uploadMethod?;
@@ -16,7 +16,7 @@ declare class GCSLoggerStream extends Writable {
16
16
  private buffer;
17
17
  private writePromise?;
18
18
  private cleanUpCalled;
19
- constructor({ logger, uploadMethod, options }: GCSLoggerStream.Config);
19
+ constructor({ logger, uploadMethod, options }: RemoteLoggerStream.Config);
20
20
  private findNormalizedHeader;
21
21
  init(): Promise<string>;
22
22
  cleanUp(): Promise<void>;
@@ -27,7 +27,7 @@ declare class GCSLoggerStream extends Writable {
27
27
  private upload;
28
28
  private createCompressedStream;
29
29
  }
30
- declare namespace GCSLoggerStream {
30
+ declare namespace RemoteLoggerStream {
31
31
  enum CompressionMethod {
32
32
  GZIP = "gzip",
33
33
  BR = "br"
@@ -37,7 +37,7 @@ declare namespace GCSLoggerStream {
37
37
  compress?: CompressionMethod;
38
38
  }
39
39
  type UploadMethod = {
40
- signedUrl: GCS.SignedUrl;
40
+ signedUrl: SignedUrl;
41
41
  };
42
42
  interface Config {
43
43
  logger: bunyan;
@@ -45,4 +45,4 @@ declare namespace GCSLoggerStream {
45
45
  options: Options;
46
46
  }
47
47
  }
48
- export default GCSLoggerStream;
48
+ export default RemoteLoggerStream;
@@ -11,9 +11,9 @@ const path_1 = __importDefault(require("path"));
11
11
  const stream_1 = require("stream");
12
12
  const util_1 = require("util");
13
13
  const zlib_1 = __importDefault(require("zlib"));
14
- const client_1 = require("./client");
14
+ const uploadWithSignedUrl_1 = require("../storage/uploadWithSignedUrl");
15
15
  const pipe = (0, util_1.promisify)(stream_1.pipeline);
16
- class GCSLoggerStream extends stream_1.Writable {
16
+ class RemoteLoggerStream extends stream_1.Writable {
17
17
  writable = true;
18
18
  logger;
19
19
  uploadMethod;
@@ -74,7 +74,7 @@ class GCSLoggerStream extends stream_1.Writable {
74
74
  await this.flush(true);
75
75
  await fs_extra_1.default.remove(this.temporaryLogsPath);
76
76
  await fs_extra_1.default.remove(this.temporaryCompressedLogsPath);
77
- this.logger.info('Cleaning up GCS log stream');
77
+ this.logger.info('Cleaning up log stream');
78
78
  }
79
79
  write(rec) {
80
80
  if (!this.fileHandle) {
@@ -102,7 +102,7 @@ class GCSLoggerStream extends stream_1.Writable {
102
102
  this.hasChangesToUpload = true;
103
103
  }
104
104
  catch (err) {
105
- this.logger.error({ err, origin: 'gcs-logger' }, 'Failed to write logs to file');
105
+ this.logger.error({ err, origin: 'remote-logger' }, 'Failed to write logs to file');
106
106
  }
107
107
  finally {
108
108
  this.writePromise = undefined;
@@ -129,7 +129,7 @@ class GCSLoggerStream extends stream_1.Writable {
129
129
  return result;
130
130
  })
131
131
  .catch(err => {
132
- this.logger.error({ err }, 'Failed to upload logs file to GCS');
132
+ this.logger.error({ err }, 'Failed to upload logs file');
133
133
  })
134
134
  .then(result => {
135
135
  this.uploadingPromise = undefined;
@@ -145,7 +145,7 @@ class GCSLoggerStream extends stream_1.Writable {
145
145
  const srcGeneratorAsync = async () => {
146
146
  return await this.createCompressedStream(fs_extra_1.default.createReadStream(this.temporaryLogsPath, { end: size }));
147
147
  };
148
- return await client_1.GCS.uploadWithSignedUrl({
148
+ return await (0, uploadWithSignedUrl_1.uploadWithSignedUrl)({
149
149
  signedUrl: this.uploadMethod.signedUrl,
150
150
  srcGeneratorAsync,
151
151
  });
@@ -170,11 +170,11 @@ class GCSLoggerStream extends stream_1.Writable {
170
170
  return fs_extra_1.default.createReadStream(this.temporaryCompressedLogsPath, { end: size });
171
171
  }
172
172
  }
173
- (function (GCSLoggerStream) {
173
+ (function (RemoteLoggerStream) {
174
174
  let CompressionMethod;
175
175
  (function (CompressionMethod) {
176
176
  CompressionMethod["GZIP"] = "gzip";
177
177
  CompressionMethod["BR"] = "br";
178
- })(CompressionMethod = GCSLoggerStream.CompressionMethod || (GCSLoggerStream.CompressionMethod = {}));
179
- })(GCSLoggerStream || (GCSLoggerStream = {}));
180
- exports.default = GCSLoggerStream;
178
+ })(CompressionMethod = RemoteLoggerStream.CompressionMethod || (RemoteLoggerStream.CompressionMethod = {}));
179
+ })(RemoteLoggerStream || (RemoteLoggerStream = {}));
180
+ exports.default = RemoteLoggerStream;
@@ -17,6 +17,7 @@ export interface JUnitTestCaseResult {
17
17
  tags: string[];
18
18
  properties: Record<string, string>;
19
19
  }
20
+ export declare function parseFailedFlowNamesFromJUnitFile(junitFile: string): Promise<Set<string>>;
20
21
  export declare function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]>;
21
22
  export declare function isFileAttrRun(testcases: JUnitTestCaseResult[]): testcases is (JUnitTestCaseResult & {
22
23
  file: string;
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseFailedFlowNamesFromJUnitFile = parseFailedFlowNamesFromJUnitFile;
6
7
  exports.parseJUnitTestCases = parseJUnitTestCases;
7
8
  exports.isFileAttrRun = isFileAttrRun;
8
9
  exports.junitFileHasFileAttrs = junitFileHasFileAttrs;
@@ -99,6 +100,17 @@ async function parseJUnitFile(filePath) {
99
100
  return [];
100
101
  }
101
102
  }
103
+ // Raw names (slashes kept) of the testcases a single JUnit file marks failed. Best-effort: an
104
+ // unreadable or malformed file yields an empty set rather than throwing.
105
+ async function parseFailedFlowNamesFromJUnitFile(junitFile) {
106
+ const failed = new Set();
107
+ for (const testcase of await parseJUnitFile(junitFile)) {
108
+ if (testcase.status === 'failed') {
109
+ failed.add(testcase.name);
110
+ }
111
+ }
112
+ return failed;
113
+ }
102
114
  async function parseJUnitTestCases(junitDirectory) {
103
115
  let entries;
104
116
  try {
@@ -18,6 +18,7 @@ export declare function harvestFailureScreenshotsAsync(args: {
18
18
  testsDirectory: string;
19
19
  capturedSinceMs: number;
20
20
  attemptIndex: number;
21
+ failedFlowNames: ReadonlySet<string>;
21
22
  logger: bunyan;
22
23
  }): Promise<HarvestedScreenshot[]>;
23
24
  export declare function computePureFailureFlowNames(testCases: readonly {
@@ -9,10 +9,18 @@ exports.computePureFailureFlowNames = computePureFailureFlowNames;
9
9
  exports.selectFailureScreenshots = selectFailureScreenshots;
10
10
  const promises_1 = __importDefault(require("fs/promises"));
11
11
  const path_1 = __importDefault(require("path"));
12
- // Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
12
+ // pre-v2.7.0 (Maestro <= 2.6.x): failure screenshots are flat files named
13
+ // `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png` directly in the session dir.
13
14
  // The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
14
- // `).png` suffix rather than scanning for parens.
15
+ // `).png` suffix rather than scanning for parens. Remove this path once the build fleet is
16
+ // entirely on Maestro >= 2.7.0.
15
17
  const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
18
+ // Maestro >= 2.7.0 bundle layout. Each flow gets its own dir under the session dir, with step
19
+ // screenshots under `screenshots/` named `step-<NNN>-<slug>.png` (NNN is a 1-based, zero-padded
20
+ // monotonic step sequence). Under the CLI (no `--analyze`) only failed/warned steps produce one.
21
+ const STEP_SCREENSHOTS_DIRNAME = 'screenshots';
22
+ const STEP_SCREENSHOT_PATTERN = /^step-(\d+)(?:-.*)?\.png$/;
23
+ // pre-v2.7.0: parses the legacy flat failure-screenshot filename.
16
24
  function parseFailureScreenshotFilename(filename) {
17
25
  const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
18
26
  if (!match) {
@@ -27,61 +35,186 @@ function parseFailureScreenshotFilename(filename) {
27
35
  capturedAtMs,
28
36
  };
29
37
  }
30
- // Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
31
- // the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
32
- // whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
33
- // and skipped so a screenshot harvest can't affect the maestro step outcome.
38
+ // Harvests one attempt's failure screenshots from the maestro debug output. Walks each session
39
+ // dir under testsDirectory whose mtime is recent enough (mtime-based to also cover the maestro
40
+ // <2.5.0 bug that splits one invocation across two dirs). Within a session dir, a child is either
41
+ // a flat pre-v2.7.0 screenshot file or a Maestro >= 2.7.0 per-flow bundle dir the single
42
+ // structural seam below. Never throws: an unreadable dir/file is logged and skipped so a
43
+ // screenshot harvest can't affect the maestro step outcome.
34
44
  async function harvestFailureScreenshotsAsync(args) {
35
45
  // Gate dirs by mtime floored to the second, so a dir created within the same second as the
36
- // attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
46
+ // attempt start isn't stat'd below the baseline. The exact capturedAtMs gate per file is what
37
47
  // prevents mis-attributing an earlier attempt's screenshot.
38
48
  const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
39
- let entries;
49
+ // Bundle dir names already have '/' -> '_'; normalize the JUnit names the same way to match.
50
+ const normalizedFailedFlowNames = new Set([...args.failedFlowNames].map(normalizeFlowName));
51
+ let sessionEntries;
40
52
  try {
41
- entries = await promises_1.default.readdir(args.testsDirectory, { withFileTypes: true });
53
+ sessionEntries = await promises_1.default.readdir(args.testsDirectory, { withFileTypes: true });
42
54
  }
43
55
  catch (err) {
44
56
  args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
45
57
  return [];
46
58
  }
47
- const results = [];
48
- for (const entry of entries) {
49
- if (!entry.isDirectory()) {
59
+ const legacyShots = [];
60
+ const bundleShots = [];
61
+ for (const sessionEntry of sessionEntries) {
62
+ if (!sessionEntry.isDirectory()) {
50
63
  continue;
51
64
  }
52
- const dirPath = path_1.default.join(args.testsDirectory, entry.name);
53
- let filenames;
65
+ const sessionDir = path_1.default.join(args.testsDirectory, sessionEntry.name);
66
+ let children;
54
67
  try {
55
- if ((await promises_1.default.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
68
+ if ((await promises_1.default.stat(sessionDir)).mtimeMs < dirSinceMtimeMs) {
56
69
  continue;
57
70
  }
58
- filenames = await promises_1.default.readdir(dirPath);
71
+ children = await promises_1.default.readdir(sessionDir, { withFileTypes: true });
59
72
  }
60
73
  catch (err) {
61
- args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
74
+ args.logger.info({ err }, `Skipping unreadable debug dir ${sessionDir}.`);
62
75
  continue;
63
76
  }
64
- for (const filename of filenames) {
65
- const parsed = parseFailureScreenshotFilename(filename);
66
- // Re-check capture time per file: a dir's mtime can advance after this attempt began (or
67
- // fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
68
- // earlier attempt's screenshot to this one.
69
- if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
70
- continue;
77
+ for (const child of children) {
78
+ if (child.isDirectory()) {
79
+ // Maestro >= 2.7.0: a per-flow bundle dir.
80
+ const shot = await harvestFromBundleAsync({
81
+ bundleDir: path_1.default.join(sessionDir, child.name),
82
+ bundleName: child.name,
83
+ normalizedFailedFlowNames,
84
+ capturedSinceMs: args.capturedSinceMs,
85
+ attemptIndex: args.attemptIndex,
86
+ logger: args.logger,
87
+ });
88
+ if (shot !== null) {
89
+ bundleShots.push(shot);
90
+ }
71
91
  }
72
- results.push({
73
- fileAbsPath: path_1.default.join(dirPath, filename),
74
- displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
75
- metadata: {
76
- kind: 'maestro-test-screenshot',
77
- flowName: parsed.flowName,
92
+ else {
93
+ // pre-v2.7.0: a flat failure screenshot file in the session dir.
94
+ const shot = harvestLegacyFlatShot({
95
+ fileName: child.name,
96
+ sessionDir,
97
+ capturedSinceMs: args.capturedSinceMs,
78
98
  attemptIndex: args.attemptIndex,
79
- capturedAtMs: parsed.capturedAtMs,
80
- },
81
- });
99
+ });
100
+ if (shot !== null) {
101
+ legacyShots.push(shot);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ // The website shows one screenshot per (flow, attempt). Two v2.7.0 bundle dirs can resolve to
107
+ // the same flow — a collision dir (`<flow>-2`) or a duplicate flow name — so keep the latest.
108
+ // Legacy shots need no dedupe: a flat filename is emitted once per failed flow, so there is no
109
+ // `<flow>-2` collision to collapse (and deduping them would change historical behavior).
110
+ return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
111
+ }
112
+ // Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
113
+ // screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
114
+ // the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
115
+ // lower numbers. Best-effort limitation: without reading Maestro's per-command JSON we can't tell a
116
+ // failed-step frame from a warned-step frame, so if the failing command captured nothing (e.g. a
117
+ // non-visible command like runScript) but an earlier optional step warned, we return that warned
118
+ // frame instead. Returns null when the bundle isn't a failed flow, its name is ambiguous, or it has
119
+ // no usable step screenshot. Never throws.
120
+ async function harvestFromBundleAsync(args) {
121
+ const flowName = resolveBundleFlowName(args.bundleName, args.normalizedFailedFlowNames);
122
+ if (flowName === null) {
123
+ return null;
124
+ }
125
+ const screenshotsDir = path_1.default.join(args.bundleDir, STEP_SCREENSHOTS_DIRNAME);
126
+ let filenames;
127
+ try {
128
+ filenames = await promises_1.default.readdir(screenshotsDir);
129
+ }
130
+ catch {
131
+ // No screenshots/ dir — e.g. the flow failed on a non-visible command, which captures none.
132
+ return null;
133
+ }
134
+ let best = null;
135
+ for (const filename of filenames) {
136
+ const match = filename.match(STEP_SCREENSHOT_PATTERN);
137
+ if (match === null) {
138
+ continue; // final.png and any non-step file
139
+ }
140
+ const stepIndex = Number(match[1]);
141
+ const fileAbsPath = path_1.default.join(screenshotsDir, filename);
142
+ let capturedAtMs;
143
+ try {
144
+ capturedAtMs = Math.round((await promises_1.default.stat(fileAbsPath)).mtimeMs);
145
+ }
146
+ catch (err) {
147
+ args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
148
+ continue;
149
+ }
150
+ if (capturedAtMs < args.capturedSinceMs) {
151
+ continue; // a prior attempt's frame (bundle dir touched later than this attempt started)
152
+ }
153
+ if (best === null || stepIndex > best.stepIndex) {
154
+ best = { fileAbsPath, stepIndex, capturedAtMs };
155
+ }
156
+ }
157
+ if (best === null) {
158
+ return null;
159
+ }
160
+ return {
161
+ fileAbsPath: best.fileAbsPath,
162
+ displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
163
+ metadata: {
164
+ kind: 'maestro-test-screenshot',
165
+ flowName,
166
+ attemptIndex: args.attemptIndex,
167
+ capturedAtMs: best.capturedAtMs,
168
+ },
169
+ };
170
+ }
171
+ // Maestro names a bundle dir `<cleanFlow>` optionally + `-shard-<n>` (sharded) + `-<n>` (name
172
+ // collision), where cleanFlow is the flow name with '/' -> '_'. Those suffixes share a namespace
173
+ // with legitimate flow names, so we can't strip blindly (a flow may be named `x-shard-1` or `x-2`).
174
+ // Enumerate the ways the dir name could peel back to a flow name, keep those that match a failed
175
+ // flow, and use the result only if exactly one fits — otherwise skip rather than guess.
176
+ function resolveBundleFlowName(bundleName, normalizedFailedFlowNames) {
177
+ const withoutCollision = bundleName.replace(/-\d+$/, '');
178
+ const preimages = [
179
+ bundleName,
180
+ withoutCollision,
181
+ withoutCollision.replace(/-shard-\d+$/, ''),
182
+ bundleName.replace(/-shard-\d+$/, ''),
183
+ ];
184
+ const matches = new Set(preimages.filter(name => normalizedFailedFlowNames.has(name)));
185
+ return matches.size === 1 ? [...matches][0] : null;
186
+ }
187
+ // Keep one screenshot per flow name (attemptIndex is constant within a harvest call), preferring
188
+ // the latest capture when two bundles resolve to the same flow.
189
+ function dedupeBundleShotsByFlowName(shots) {
190
+ const byFlowName = new Map();
191
+ for (const shot of shots) {
192
+ const existing = byFlowName.get(shot.metadata.flowName);
193
+ if (existing === undefined || shot.metadata.capturedAtMs > existing.metadata.capturedAtMs) {
194
+ byFlowName.set(shot.metadata.flowName, shot);
82
195
  }
83
196
  }
84
- return results;
197
+ return [...byFlowName.values()];
198
+ }
199
+ // pre-v2.7.0 (Maestro <= 2.6.x) reader: a flat `screenshot-...❌...png` file whose name carries the
200
+ // flow name and capture epoch.
201
+ function harvestLegacyFlatShot(args) {
202
+ const parsed = parseFailureScreenshotFilename(args.fileName);
203
+ // Re-check capture time per file — a dir's mtime can advance after this attempt began, so the dir
204
+ // gate alone would re-attribute an earlier attempt's screenshot to this one.
205
+ if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
206
+ return null;
207
+ }
208
+ return {
209
+ fileAbsPath: path_1.default.join(args.sessionDir, args.fileName),
210
+ displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
211
+ metadata: {
212
+ kind: 'maestro-test-screenshot',
213
+ flowName: parsed.flowName,
214
+ attemptIndex: args.attemptIndex,
215
+ capturedAtMs: parsed.capturedAtMs,
216
+ },
217
+ };
85
218
  }
86
219
  // Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
87
220
  // is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
@@ -216,10 +216,14 @@ function createMaestroTestsBuildFunction(ctx) {
216
216
  // junit: test-case-result rows (and therefore the summary icons) only exist for junit
217
217
  // runs, so harvesting other formats would just create orphan artifacts the website hides.
218
218
  if (outputFormat === 'junit') {
219
+ const failedFlowNames = outputPath
220
+ ? await (0, maestroResultParser_1.parseFailedFlowNamesFromJUnitFile)(outputPath)
221
+ : new Set();
219
222
  harvested.push(...(await (0, maestroScreenshots_1.harvestFailureScreenshotsAsync)({
220
223
  testsDirectory,
221
224
  capturedSinceMs: attemptStartedAtMs,
222
225
  attemptIndex: attempt,
226
+ failedFlowNames,
223
227
  logger,
224
228
  })));
225
229
  }
@@ -5,7 +5,7 @@ type RetryOptions = {
5
5
  shouldRetryOnError: (error: unknown) => boolean;
6
6
  shouldRetryOnResponse: (response: Response) => boolean;
7
7
  };
8
- export declare function retryOnGCSUploadFailure(fn: (attemptCount: number) => Promise<Response>, { retries, retryIntervalMs, }: {
8
+ export declare function retryOnUploadFailure(fn: (attemptCount: number) => Promise<Response>, { retries, retryIntervalMs, }: {
9
9
  retries: RetryOptions['retries'];
10
10
  retryIntervalMs: RetryOptions['retryIntervalMs'];
11
11
  }): Promise<Response>;
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.retryOnGCSUploadFailure = retryOnGCSUploadFailure;
3
+ exports.retryOnUploadFailure = retryOnUploadFailure;
4
4
  // based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
5
- async function retryOnGCSUploadFailure(fn, { retries, retryIntervalMs, }) {
5
+ async function retryOnUploadFailure(fn, { retries, retryIntervalMs, }) {
6
6
  return await retry(fn, {
7
7
  retries,
8
8
  retryIntervalMs,
@@ -0,0 +1,12 @@
1
+ import { Readable } from 'stream';
2
+ export type SignedUrl = {
3
+ url: string;
4
+ headers: Record<string, string>;
5
+ };
6
+ export type UploadWithSignedUrlParams = {
7
+ signedUrl: SignedUrl;
8
+ srcGeneratorAsync: () => Promise<Readable>;
9
+ retryIntervalMs?: number;
10
+ retries?: number;
11
+ };
12
+ export declare function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries, retryIntervalMs, }: UploadWithSignedUrlParams): Promise<string>;
@@ -33,40 +33,36 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.GCS = void 0;
36
+ exports.uploadWithSignedUrl = uploadWithSignedUrl;
37
37
  const node_fetch_1 = __importStar(require("node-fetch"));
38
38
  const url_1 = require("url");
39
39
  const retry_1 = require("./retry");
40
- var GCS;
41
- (function (GCS) {
42
- async function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries = 2, retryIntervalMs = 30_000, }) {
43
- let resp;
44
- try {
45
- resp = await (0, retry_1.retryOnGCSUploadFailure)(async () => {
46
- const src = await srcGeneratorAsync();
47
- return await (0, node_fetch_1.default)(signedUrl.url, {
48
- method: 'PUT',
49
- headers: signedUrl.headers,
50
- body: src,
51
- });
52
- }, { retries, retryIntervalMs });
53
- }
54
- catch (err) {
55
- if (err instanceof node_fetch_1.FetchError) {
56
- throw new Error(`Failed to upload the file, reason: ${err.code}`);
57
- }
58
- throw err;
40
+ async function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries = 2, retryIntervalMs = 30_000, }) {
41
+ let resp;
42
+ try {
43
+ resp = await (0, retry_1.retryOnUploadFailure)(async () => {
44
+ const src = await srcGeneratorAsync();
45
+ return await (0, node_fetch_1.default)(signedUrl.url, {
46
+ method: 'PUT',
47
+ headers: signedUrl.headers,
48
+ body: src,
49
+ });
50
+ }, { retries, retryIntervalMs });
51
+ }
52
+ catch (err) {
53
+ if (err instanceof node_fetch_1.FetchError) {
54
+ throw new Error(`Failed to upload the file, reason: ${err.code}`);
59
55
  }
60
- if (!resp.ok) {
61
- let body;
62
- try {
63
- body = await resp.text();
64
- }
65
- catch { }
66
- throw new Error(`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`);
56
+ throw err;
57
+ }
58
+ if (!resp.ok) {
59
+ let body;
60
+ try {
61
+ body = await resp.text();
67
62
  }
68
- const url = new url_1.URL(signedUrl.url);
69
- return `${url.protocol}//${url.host}${url.pathname}`;
63
+ catch { }
64
+ throw new Error(`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`);
70
65
  }
71
- GCS.uploadWithSignedUrl = uploadWithSignedUrl;
72
- })(GCS || (exports.GCS = GCS = {}));
66
+ const url = new url_1.URL(signedUrl.url);
67
+ return `${url.protocol}//${url.host}${url.pathname}`;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/build-tools",
3
- "version": "21.0.2",
3
+ "version": "21.0.3",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -39,14 +39,14 @@
39
39
  "@expo/config": "55.0.10",
40
40
  "@expo/config-plugins": "55.0.7",
41
41
  "@expo/downloader": "21.0.0",
42
- "@expo/eas-build-job": "21.0.2",
42
+ "@expo/eas-build-job": "21.0.3",
43
43
  "@expo/env": "^0.4.0",
44
44
  "@expo/logger": "21.0.0",
45
45
  "@expo/package-manager": "1.9.10",
46
46
  "@expo/plist": "^0.3.5",
47
47
  "@expo/results": "^1.0.0",
48
48
  "@expo/spawn-async": "1.7.2",
49
- "@expo/steps": "21.0.2",
49
+ "@expo/steps": "21.0.3",
50
50
  "@expo/template-file": "21.0.2",
51
51
  "@expo/turtle-spawn": "21.0.0",
52
52
  "@expo/xcpretty": "^4.3.1",
@@ -100,5 +100,5 @@
100
100
  "typescript": "^5.5.4",
101
101
  "uuid": "^9.0.1"
102
102
  },
103
- "gitHead": "e9ebc41413ae2b5c7b4b92e91f45e9f65e3dbd23"
103
+ "gitHead": "62d46fece55ab5b27c2a572fc91158214b9ae96b"
104
104
  }
@@ -1,15 +0,0 @@
1
- import { Readable } from 'stream';
2
- interface UploadWithSignedUrlParams {
3
- signedUrl: GCS.SignedUrl;
4
- srcGeneratorAsync: () => Promise<Readable>;
5
- retryIntervalMs?: number;
6
- retries?: number;
7
- }
8
- export declare namespace GCS {
9
- type SignedUrl = {
10
- url: string;
11
- headers: Record<string, string>;
12
- };
13
- function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries, retryIntervalMs, }: UploadWithSignedUrlParams): Promise<string>;
14
- }
15
- export {};