Compare commits

..
Author SHA1 Message Date
Stanisław Chmiela e507735166 [worker] Add a packaged startup wrapper 2026-10-22 13:17:51 +02:00
42 changed files with 363 additions and 443 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Show the final Google Play rejection when a retried release commit has an uncertain outcome.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Retry recognized Google Play processing and review-setting rejections after transient request failures, while preserving uncertain commit outcomes.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/build-tools': patch
'@expo/steps': patch
---
Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/eas-build-job': major
'@expo/build-tools': major
---
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
+1 -1
View File
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Workflow artifacts for any file
- Generic artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
@@ -16,13 +16,11 @@ function createCtx(
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
): {
ctx: BuildContext<Ios.Job>;
info: jest.Mock;
warn: jest.Mock;
} {
const info = jest.fn();
const warn = jest.fn();
const logger = {
info,
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn,
@@ -38,7 +36,7 @@ function createCtx(
workingdir,
}
);
return { ctx, info, warn };
return { ctx, warn };
}
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
@@ -81,31 +79,22 @@ describe(parseJobHooksAsync, () => {
});
});
it('writes nothing to the build log while parsing', async () => {
const { ctx, info, warn } = createCtx({
before_install_node_modules: [{ run: 'echo hi' }],
before_submit: [{ run: 'echo hi' }],
before_bogus: [{ run: 'echo hi' }],
});
await parseJobHooksAsync(ctx, INSTALL);
expect(info).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it('treats an unknown hook key as inert', async () => {
const { ctx } = createCtx({
it('treats an unknown hook key as inert and warns', async () => {
const { ctx, warn } = createCtx({
before_bogus: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
});
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
const { ctx } = createCtx({
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
const { ctx, warn } = createCtx({
before_submit: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
@@ -203,9 +192,10 @@ describe(parseJobHooksAsync, () => {
});
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
@@ -44,8 +44,9 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
if (hooks) {
try {
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
} catch {
// The phase error wins.
} catch (hookError) {
// The phase error wins; still surface the after-hook failure in the log.
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
}
}
throw phaseError;
+16 -5
View File
@@ -53,17 +53,19 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
// Hook steps run in the checked-out project directory, not the pre-checkout
// target directory the global context defaults to.
globalContext.markAsCheckedOut();
globalContext.markAsCheckedOut(ctx.logger);
const externalFunctions = getEasFunctions(customBuildContext);
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
// a malformed `before_submit` fails a steps build, so it fails here too). An
// unknown key is inert — a key newer than this worker must be ignored, never
// fail the job.
// a malformed `before_submit` fails a steps build, so it fails here too), and
// warn on keys this build won't run. An unknown key is inert — a key newer
// than this worker must be ignored, never fail the job.
for (const [key, steps] of Object.entries(hooks)) {
if (parseHookKey(key) === null) {
const parsed = parseHookKey(key);
if (parsed === null) {
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
continue;
}
if (Array.isArray(steps) && steps.length === 0) {
@@ -79,6 +81,15 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
{ cause: err }
);
}
// Registered but not wrapped by this build: shape-checked above, then
// skipped. Function existence is deliberately NOT checked (that happens only
// during construction) — an unknown `uses:` under an unwrapped key warns
// rather than failing the build.
if (!wrappedAnchors.includes(parsed.anchorId)) {
ctx.logger.warn(
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
);
}
}
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
+4 -2
View File
@@ -4,11 +4,13 @@ import {
BuildPhaseResult,
Env,
EnvironmentSecretType,
GenericArtifactType,
Job,
LogMarker,
ManagedArtifactType,
Metadata,
errors,
isGenericArtifact,
} from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { bunyan } from '@expo/logger';
@@ -40,7 +42,7 @@ export type ArtifactToUpload =
paths: string[];
}
| {
type: null;
type: GenericArtifactType;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
@@ -239,7 +241,7 @@ export class BuildContext<TJob extends Job = Job> {
logger: bunyan;
}): Promise<{ artifactId: string | null }> {
const result = await this._uploadArtifact({ artifact, logger });
if (result.filename && artifact.type !== null) {
if (result.filename && !isGenericArtifact(artifact)) {
this.artifacts[artifact.type] = result.filename;
}
return { artifactId: result.artifactId ?? null };
@@ -48,10 +48,7 @@ describe(createCheckoutBuildFunction, () => {
});
it('moves project sources to the target directory when no inputs are given', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
});
@@ -59,9 +56,6 @@ describe(createCheckoutBuildFunction, () => {
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
expect(globalCtx.wasCheckedOut()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Changing default working directory to')
);
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
});
@@ -102,11 +96,14 @@ describe(createCheckoutBuildFunction, () => {
});
it('rejects the ref input when the project is already checked out', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
callInputs: { ref: 'feature/add-icon' },
});
globalCtx.markAsCheckedOut();
globalCtx.markAsCheckedOut(logger);
await expect(executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
@@ -305,6 +305,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
@@ -1090,6 +1091,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
@@ -1418,6 +1420,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -198,10 +198,7 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
return [204];
});
const task = submission();
const error = await task.run().catch(error => error);
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toContain('HTTP 404');
expect((error as Error).message).toContain('may still have saved');
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
expect(deleted).toBe(false);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
});
@@ -121,32 +121,26 @@ describe(createUploadArtifactBuildFunction, () => {
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
'uploads %s as a workflow artifact with metadata',
async type => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
createGlobalContextMock({}),
{
callInputs: {
type,
key: 'Screen Recording',
path: '/',
metadata,
},
}
);
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
await buildStep.executeAsync();
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({ type: null, metadata }),
})
);
}
);
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
@@ -58,11 +58,7 @@ export function createCheckoutBuildFunction(): BuildFunction {
});
}
const previousWorkingDirectory = stepsCtx.global.defaultWorkingDirectory;
stepsCtx.global.markAsCheckedOut();
stepsCtx.logger.info(
`Changing default working directory to ${stepsCtx.global.defaultWorkingDirectory} (was ${previousWorkingDirectory})`
);
stepsCtx.global.markAsCheckedOut(stepsCtx.logger);
},
});
}
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -631,7 +631,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
try {
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: null,
type: GenericArtifactType.OTHER,
name: selectedReport.name,
paths: [selectedReport.artifactPath],
},
@@ -738,7 +738,7 @@ async function uploadFailureScreenshotsAsync({
await fs.copyFile(shot.fileAbsPath, safePath);
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: null,
type: GenericArtifactType.OTHER,
name: shot.displayName,
paths: [safePath],
metadata: shot.metadata,
@@ -1,4 +1,9 @@
import { Job, ManagedArtifactType } from '@expo/eas-build-job';
import {
GenericArtifactType,
Job,
ManagedArtifactType,
isGenericArtifact,
} from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -28,12 +33,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
ManagedArtifactType.APPLICATION_ARCHIVE,
ManagedArtifactType.BUILD_ARTIFACTS,
...Object.keys(artifactTypeInputToManagedArtifactType),
// Keep accepting these legacy artifact types for compatibility.
'android-apk',
'android-aab',
'ios-simulator-app',
'ios-ipa',
'other',
...Object.values(GenericArtifactType),
],
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -126,13 +126,12 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
paths: artifactPaths,
name: (inputs.name.value || inputs.key.value) as string,
};
const artifact =
artifactSpec.type === null
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
const artifact = isGenericArtifact(artifactSpec)
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
@@ -142,7 +141,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error({ err: error }, 'Failed to upload artifact. Ignoring error.');
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
// Ignoring error.
return;
}
@@ -159,7 +158,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
* values. This makes sure we support all:
* - kebab-case managed artifact types (the original)
* - snake-caps-case managed artifact types (the mistake)
* - workflow artifact types.
* - generic artifact types.
*/
function parseArtifactTypeInput({
inputValue,
@@ -167,15 +166,19 @@ function parseArtifactTypeInput({
}: {
inputValue: string;
platform: Job['platform'];
}): ManagedArtifactType | null {
}): GenericArtifactType | ManagedArtifactType {
if (!inputValue) {
// In build jobs the default artifact type is application-archive.
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : null;
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER;
}
return (
artifactTypeInputToManagedArtifactType[inputValue] ??
Object.values(ManagedArtifactType).find(type => type === inputValue) ??
null
);
// Step's allowedValues ensures input is either
// a key of artifactTypeInputToManagedArtifactType
// or a value of an artifact type.
const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue];
if (translatedManagedArtifactType) {
return translatedManagedArtifactType;
}
return inputValue as GenericArtifactType | ManagedArtifactType;
}
@@ -144,16 +144,6 @@ export namespace GooglePlayUtils {
signal?: AbortSignal;
}
): Promise<void> {
let uncertainCommitError: GooglePlayCommitOutcomeUnknownError | undefined;
const lastCommitError = (error: unknown): unknown => {
while (
error instanceof GooglePlayCommitOutcomeUnknownError ||
error instanceof AggregateError
) {
error = error.cause;
}
return error;
};
const sendCommitAsync = async ({
changesNotSentForReview,
}: {
@@ -175,32 +165,13 @@ export namespace GooglePlayUtils {
throw error;
}
if (
uncertainCommitError ||
!(error instanceof GooglePlayApiError) ||
GooglePlayApiError.isRetryableStatus(error.status)
) {
const cause = uncertainCommitError
? new AggregateError(
[uncertainCommitError, error],
'Google Play commit attempts failed.',
{ cause: error }
)
: error;
const lastError = lastCommitError(error);
const lastResponse =
lastError instanceof GooglePlayApiError &&
lastError.status >= 400 &&
lastError.status < 500 &&
!GooglePlayApiError.isRetryableStatus(lastError.status)
? `Google Play returned HTTP ${lastError.status} for the last commit attempt${lastError.apiMessage ? `: ${lastError.apiMessage}` : '.'}\n`
: '';
uncertainCommitError = new GooglePlayCommitOutcomeUnknownError(
lastResponse
? `${lastResponse}An earlier commit attempt failed with a temporary error, so Google Play may still have saved the release for ${packageName}. Check its release status at https://play.google.com/console/. If the release is not there, fix the problem above and submit again.`
: `Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
{ cause, metadata: GooglePlayApiError.getErrorMetadata(error) }
throw new GooglePlayCommitOutcomeUnknownError(
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
throw uncertainCommitError;
}
throw error;
}
@@ -212,14 +183,10 @@ export namespace GooglePlayUtils {
};
// Google can accept the bundle upload before the edit is ready to commit.
// Retry only this explicit rejection, not other HTTP 400 preconditions.
const isUploadStillProcessing = (error: unknown): boolean => {
const lastError = lastCommitError(error);
return (
lastError instanceof GooglePlayApiError &&
lastError.status === 400 &&
lastError.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.'
);
};
const isUploadStillProcessing = (error: unknown): boolean =>
error instanceof GooglePlayApiError &&
error.status === 400 &&
error.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.';
const commitAsync = async (options: { changesNotSentForReview?: boolean }): Promise<void> => {
try {
@@ -237,21 +204,7 @@ export namespace GooglePlayUtils {
}
)(options);
} catch (error) {
if (uncertainCommitError && !(error instanceof GooglePlayCommitOutcomeUnknownError)) {
// Cancellation or a later authentication failure cannot establish the earlier outcome.
throw new GooglePlayCommitOutcomeUnknownError(uncertainCommitError.message, {
cause: new AggregateError(
[uncertainCommitError, error],
'Google Play commit recovery stopped.',
{ cause: error }
),
metadata: uncertainCommitError.metadata,
});
}
if (
isUploadStillProcessing(error) &&
!(error instanceof GooglePlayCommitOutcomeUnknownError)
) {
if (isUploadStillProcessing(error)) {
throw new SystemError(
`Google Play did not finish processing the uploaded bundle for ${packageName} within five minutes. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
@@ -265,14 +218,13 @@ export namespace GooglePlayUtils {
// reject its presence, even when false. True saves changes for review in Console.
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
} catch (error) {
const lastError = lastCommitError(error);
if (!(lastError instanceof GooglePlayApiError) || lastError.status !== 400) {
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
throw error;
}
// Like Fastlane, retry only an explicit rejection of the review setting.
if (
changesNotSentForReview &&
lastError.apiMessage.includes('The query parameter changesNotSentForReview must not be set')
error.apiMessage.includes('The query parameter changesNotSentForReview must not be set')
) {
logger.warn(
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
@@ -280,9 +232,7 @@ export namespace GooglePlayUtils {
await commitAsync({});
} else if (
!changesNotSentForReview &&
lastError.apiMessage.includes(
'Please set the query parameter changesNotSentForReview to true'
)
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
) {
logger.warn(
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
@@ -84,23 +84,6 @@ it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit',
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
await expect(commit()).resolves.toBeUndefined();
});
it('shows the final rejection after a transient commit failure without claiming the outcome is known', async () => {
mockToken();
api().post(`${editPath}:commit`).query({}).reply(503);
const message = 'You must let us know whether your app includes any health features.';
api().post(`${editPath}:commit`).query({}).reply(403, { error: { message } });
const error = await commit().catch(error => error);
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
expect(mapped).toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
expect((mapped as Error).message).toContain(`HTTP 403 for the last commit attempt: ${message}`);
expect((mapped as Error).message).toContain('may still have saved');
expect((mapped as Error).message).toContain('https://play.google.com/console/');
expect(mapped).toMatchObject({
metadata: { googlePlayErrorResponse: JSON.stringify({ error: { message } }) },
cause: expect.any(AggregateError),
});
expect(((mapped as Error).cause as AggregateError).errors).toHaveLength(2);
});
it('preserves cancellation before commit is sent', async () => {
const controller = new AbortController();
const reason = new Error('cancelled before commit');
@@ -109,37 +92,6 @@ it('preserves cancellation before commit is sent', async () => {
});
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
it.each([
[false, pendingUploadMessage, {}],
[true, 'The query parameter changesNotSentForReview must not be set', {}],
[
false,
'Please set the query parameter changesNotSentForReview to true',
{ changesNotSentForReview: 'true' },
],
] as const)(
'recovers from a wrapped rejection: %s %s',
async (changesNotSentForReview, message, retryQuery) => {
mockToken();
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
api().post(`${editPath}:commit`).query(query).reply(503);
api().post(`${editPath}:commit`).query(query).reply(400, { error: { message } });
api().post(`${editPath}:commit`).query(retryQuery).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
}
);
it('keeps an uncertain outcome when processing recovery ends in another rejection', async () => {
mockToken();
api().post(`${editPath}:commit`).query({}).reply(503);
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`).query({}).reply(404);
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
});
it.each([false, true])(
'waits for bundle processing without changing review settings (%s)',
async changesNotSentForReview => {
@@ -158,17 +110,6 @@ it.each([false, true])(
}
);
it('preserves uncertainty when wrapped processing retries are exhausted', async () => {
mockToken();
api().post(`${editPath}:commit`).query({}).reply(503);
api()
.post(`${editPath}:commit`)
.query({})
.times(14)
.reply(400, { error: { message: pendingUploadMessage } });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
});
it('stops waiting for bundle processing after 14 attempts', async () => {
mockToken();
api()
@@ -309,9 +250,7 @@ it('keeps the last Google Play error response when the commit outcome is unknown
.query({})
.times(6)
.reply(503, { error: { message: 'Backend error' } });
const error = await commit().catch(error => error);
expect(error.message).not.toContain('last commit attempt');
expect(error).toMatchObject({
await expect(commit()).rejects.toMatchObject({
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
@@ -330,19 +269,3 @@ it('does not keep the upload session query or a non-JSON response', async () =>
googlePlayErrorResponse: undefined,
});
});
it('preserves an uncertain commit if processing recovery is cancelled', async () => {
const controller = new AbortController();
const reason = new Error('cancelled after uncertain commit');
const logger = createMockLogger();
jest.spyOn(logger, 'warn').mockImplementation(() => controller.abort(reason));
mockToken();
api().post(`${editPath}:commit`).query({}).reply(503);
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
await expect(commit({ signal: controller.signal, logger })).rejects.toBeInstanceOf(
GooglePlayCommitOutcomeUnknownError
);
});
@@ -154,9 +154,8 @@ export namespace AndroidEmulatorUtils {
try {
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
// Keep the lower RAM default for older images; newer guests need more headroom for ADB.
const apiLevel = systemImagePackage.match(/^system-images;android-(\d+)/)?.[1];
const ramSize = !env.DEVICE_RUN_SESSION_ID && apiLevel && Number(apiLevel) < 35 ? 2048 : 4096;
// Give interactive sessions more headroom for guest VSOCK packet allocations.
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
logger.info(`Setting hw.ramSize to ${ramSize}.`);
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
@@ -121,7 +121,7 @@ describe('AndroidEmulatorUtils', () => {
'hw.lcd.density=420',
'skin.path=pixel_9',
'showDeviceFrame=yes',
'hw.ramSize=4096',
'hw.ramSize=2048',
'',
'hw.lcd.density=220',
'',
+21
View File
@@ -7,3 +7,24 @@ export enum ManagedArtifactType {
*/
XCODE_BUILD_LOGS = 'XCODE_BUILD_LOGS',
}
export enum GenericArtifactType {
ANDROID_APK = 'android-apk',
ANDROID_AAB = 'android-aab',
IOS_SIMULATOR_APP = 'ios-simulator-app',
IOS_IPA = 'ios-ipa',
OTHER = 'other',
}
export const isGenericArtifact = <
TSpec extends { type: GenericArtifactType | ManagedArtifactType },
>(
artifactSpec: TSpec
): artifactSpec is TSpec & { type: GenericArtifactType } => {
if (Object.values(GenericArtifactType).includes(artifactSpec.type as GenericArtifactType)) {
return true;
}
return false;
};
@@ -1,5 +1,5 @@
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient } from './contextUtils/createGraphqlClient';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
import { Actor } from '../../user/User';
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
@@ -22,7 +22,7 @@ export default class LoggedInContextField extends ContextField<LoggedInContextTy
});
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
const graphqlClient = createGraphqlClient(authenticationInfo);
return {
actor,
@@ -1,5 +1,5 @@
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient } from './contextUtils/createGraphqlClient';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
import { Actor } from '../../user/User';
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
import FeatureGating from '../gating/FeatureGating';
@@ -21,7 +21,7 @@ export default class MaybeLoggedInContextField extends ContextField<MaybeLoggedI
sessionSecret: sessionManager.getSessionSecret(),
};
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
const graphqlClient = createGraphqlClient(authenticationInfo);
const actor = await sessionManager.getUserAsync();
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
@@ -1,63 +0,0 @@
import nock from 'nock';
import { AnalyticsWithOrchestration } from '../../../analytics/AnalyticsManager';
import { getExpoApiBaseUrl } from '../../../api';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import SessionManager from '../../../user/SessionManager';
import { ContextOptions } from '../ContextField';
import LoggedInContextField from '../LoggedInContextField';
import { getProjectIdAsync } from '../contextUtils/getProjectIdAsync';
const app = {
__typename: 'App',
id: 'PROJECT_ID',
name: 'app',
fullName: '@owner/app',
slug: 'app',
ownerAccount: { __typename: 'Account', id: 'ACCOUNT_ID', name: 'owner' },
githubRepository: null,
};
beforeEach(() => {
nock.disableNetConnect();
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
describe(LoggedInContextField, () => {
it('shares the GraphQL cache with the project ID check', async () => {
const analytics: AnalyticsWithOrchestration = {
logEvent: jest.fn(),
setActor: jest.fn(),
flushAsync: jest.fn(),
getDeviceId: jest.fn(() => null),
};
const sessionManager = new SessionManager(analytics);
jest.spyOn(sessionManager, 'ensureLoggedInAsync').mockResolvedValue({
actor: { __typename: 'User', id: 'USER_ID', username: 'owner' } as any,
authenticationInfo: { accessToken: 'token', sessionSecret: null },
});
const scope = nock(getExpoApiBaseUrl())
.post('/graphql')
.once()
.reply(200, { data: { app: { __typename: 'AppQuery', byId: app } } });
const projectId = await getProjectIdAsync(
sessionManager,
{ name: 'app', slug: 'app', extra: { eas: { projectId: 'PROJECT_ID' } } },
{ nonInteractive: true }
);
const { graphqlClient } = await new LoggedInContextField().getValueAsync({
sessionManager,
nonInteractive: true,
} as ContextOptions);
expect(await AppQuery.byIdAsync(graphqlClient, projectId)).toMatchObject({
fullName: '@owner/app',
});
expect(scope.isDone()).toBe(true);
});
});
@@ -10,7 +10,6 @@ import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../
import { isExpoInstalled } from '../../../../project/projectUtils';
import { promptAsync } from '../../../../prompts';
import SessionManager from '../../../../user/SessionManager';
import { ExpoGraphqlClient } from '../createGraphqlClient';
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from '../getProjectIdAsync';
@@ -41,8 +40,6 @@ describe(getProjectIdAsync, () => {
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
@@ -189,7 +186,6 @@ describe(getProjectIdAsync, () => {
it('throws when the owner is not specified and is different than logged in user which is a robot', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'Robot',
@@ -414,7 +410,6 @@ describe(getProjectIdAsync, () => {
it('uses the only account without prompting when the user has a single account', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
@@ -453,7 +448,6 @@ describe(getProjectIdAsync, () => {
it('throws for a robot user when no owner is set', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'Robot',
@@ -515,7 +509,6 @@ describe(getProjectIdAsync, () => {
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
@@ -3,7 +3,7 @@ import { Env } from '@expo/eas-build-job';
import chalk from 'chalk';
import semver from 'semver';
import { ExpoGraphqlClient } from './createGraphqlClient';
import { ExpoGraphqlClient, createGraphqlClient } from './createGraphqlClient';
import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { learnMore } from '../../../log';
@@ -92,7 +92,7 @@ export async function getProjectIdAsync(
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive: options.nonInteractive,
});
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
const graphqlClient = createGraphqlClient(authenticationInfo);
const projectId = await validateOrSetProjectIdAsync({ exp, graphqlClient, actor, options });
return projectId;
+1 -20
View File
@@ -9,10 +9,7 @@ import { fetchUserAsync } from './fetchUser';
import { ApiV2Error } from '../ApiV2Error';
import { AnalyticsWithOrchestration } from '../analytics/AnalyticsManager';
import { ApiV2Client } from '../api';
import {
ExpoGraphqlClient,
createGraphqlClient,
} from '../commandUtils/context/contextUtils/createGraphqlClient';
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CurrentUserQuery } from '../graphql/generated';
import { UserQuery } from '../graphql/queries/UserQuery';
import Log, { learnMore } from '../log';
@@ -46,7 +43,6 @@ type Actor = NonNullable<CurrentUserQuery['meActor']>;
export default class SessionManager {
private currentActor: Actor | undefined;
private graphqlClient: { credential: string | null; client: ExpoGraphqlClient } | undefined;
constructor(private readonly analytics: AnalyticsWithOrchestration) {}
@@ -91,21 +87,6 @@ export default class SessionManager {
await this.setSessionAsync(undefined);
}
/**
* Returns one GraphQL client per credential, so context fields of one command share
* the urql cache. For example, the project ID check and the command both query the app.
*/
public getGraphqlClient(authenticationInfo: {
accessToken: string | null;
sessionSecret: string | null;
}): ExpoGraphqlClient {
const credential = authenticationInfo.accessToken ?? authenticationInfo.sessionSecret;
if (this.graphqlClient?.credential !== credential) {
this.graphqlClient = { credential, client: createGraphqlClient(authenticationInfo) };
}
return this.graphqlClient.client;
}
/**
* @param requestTimeoutMs caps the actor lookup when no actor is cached yet. Callers that must
* not delay a command past a deadline pass it; the default has no timeout. A capped lookup
@@ -5,10 +5,7 @@ import path from 'path';
import { ApiV2Error } from '../../ApiV2Error';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
import { ApiV2Client } from '../../api';
import {
ExpoGraphqlClient,
createGraphqlClient,
} from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { createGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { UserQuery } from '../../graphql/queries/UserQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
@@ -116,28 +113,6 @@ describe(SessionManager, () => {
});
});
describe('getGraphqlClient', () => {
beforeEach(() => {
jest.mocked(createGraphqlClient).mockImplementation(() => ({}) as ExpoGraphqlClient);
});
it('returns the same client for the same credential', () => {
const sessionManager = new SessionManager(analytics);
const client = sessionManager.getGraphqlClient({ accessToken: 'token', sessionSecret: null });
expect(
sessionManager.getGraphqlClient({ accessToken: 'token', sessionSecret: 'secret' })
).toBe(client);
});
it('returns a new client when the credential changes', () => {
const sessionManager = new SessionManager(analytics);
const client = sessionManager.getGraphqlClient({ accessToken: null, sessionSecret: 'a' });
expect(sessionManager.getGraphqlClient({ accessToken: null, sessionSecret: 'b' })).not.toBe(
client
);
});
});
describe('getUserAsync', () => {
it('skips fetching user without access token or session secret', async () => {
const sessionManager = new SessionManager(analytics);
+4 -1
View File
@@ -202,8 +202,11 @@ export class BuildStepGlobalContext {
return new BuildStepContext(this, options);
}
public markAsCheckedOut(): void {
public markAsCheckedOut(logger: bunyan): void {
this.didCheckOut = true;
logger.info(
`Changing default working directory to ${this.defaultWorkingDirectory} (was ${this.projectTargetDirectory})`
);
}
public get hasAnyPreviousStepFailed(): boolean {
+19 -4
View File
@@ -38,7 +38,7 @@ import {
parseLocalCompositeFunctionPath,
} from './utils/localCompositeFunctions';
type ValidatedHooks = ReadonlyMap<HookKey, Step[]>;
type ValidatedHooks = ReadonlyMap<HookKey, { anchorId: HookAnchorId; steps: Step[] }>;
export class StepsConfigParser extends AbstractConfigParser {
private readonly steps: Step[];
@@ -114,6 +114,7 @@ export class StepsConfigParser extends AbstractConfigParser {
// step ids identical across the splicing→engine rollout.
const buildSteps: BuildStep[] = [];
const hooksByAnchorStep = new Map<BuildStep, AnchorHooks>();
const seenAnchorIds = new Set<HookAnchorId>();
for (const stepConfig of validatedSteps) {
const maybeFunctionGroup =
@@ -134,6 +135,7 @@ export class StepsConfigParser extends AbstractConfigParser {
if (anchorId === undefined) {
continue;
}
seenAnchorIds.add(anchorId);
const anchorHooks = await this.constructAnchorHooksAsync(
anchorId,
validatedHooks,
@@ -160,6 +162,7 @@ export class StepsConfigParser extends AbstractConfigParser {
'Hook anchors are not supported on local composite function steps.'
);
}
seenAnchorIds.add(anchorId);
const before = await this.constructHookSideEntriesAsync(
anchorId,
'before',
@@ -187,6 +190,14 @@ export class StepsConfigParser extends AbstractConfigParser {
}
}
for (const [hookKey, { anchorId }] of validatedHooks) {
if (!seenAnchorIds.has(anchorId)) {
this.ctx.baseLogger.warn(
`Ignoring "hooks.${hookKey}": this build does not run the "${anchorId}" step.`
);
}
}
return {
buildSteps,
buildFunctionById,
@@ -195,13 +206,14 @@ export class StepsConfigParser extends AbstractConfigParser {
}
private validateHooks(): ValidatedHooks {
const validatedHooks = new Map<HookKey, Step[]>();
const validatedHooks = new Map<HookKey, { anchorId: HookAnchorId; steps: Step[] }>();
for (const [hookKey, hookSteps] of Object.entries(this.hooks)) {
// A worker must not fail on a hook key newer than itself, so unregistered
// keys skip validation entirely (their steps may reference functions this
// worker lacks).
const parsed = parseHookKey(hookKey);
if (parsed === null) {
this.ctx.baseLogger.warn(`Ignoring unknown hook key "${hookKey}".`);
continue;
}
// An empty array is a deliberate no-op (e.g. opting out of a default);
@@ -211,7 +223,10 @@ export class StepsConfigParser extends AbstractConfigParser {
continue;
}
try {
validatedHooks.set(`${parsed.side}_${parsed.anchorId}`, validateSteps(hookSteps));
validatedHooks.set(`${parsed.side}_${parsed.anchorId}`, {
anchorId: parsed.anchorId,
steps: validateSteps(hookSteps),
});
} catch (err) {
throw new BuildConfigError(
`Invalid steps in "hooks.${hookKey}": ${err instanceof Error ? err.message : String(err)}`
@@ -271,7 +286,7 @@ export class StepsConfigParser extends AbstractConfigParser {
validatedHooks: ValidatedHooks,
compositeFunctionExpander: CompositeFunctionExpander
): Promise<HookEntry[]> {
const hookSteps = validatedHooks.get(`${side}_${anchorId}`);
const hookSteps = validatedHooks.get(`${side}_${anchorId}`)?.steps;
if (hookSteps === undefined) {
return [];
}
@@ -130,7 +130,7 @@ describe(BuildStep, () => {
projectTargetDirectory: '/a/b',
relativeWorkingDirectory: 'c',
});
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
const id = 'test1';
const command = 'ls -la';
@@ -150,7 +150,7 @@ describe(BuildStep, () => {
projectTargetDirectory: '/a/b',
relativeWorkingDirectory: 'c',
});
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
const id = 'test1';
const command = 'ls -la';
@@ -65,7 +65,7 @@ describe(BuildStepGlobalContext, () => {
),
false
);
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
expect(ctx.defaultWorkingDirectory).toBe(workingDirectory);
});
});
@@ -136,7 +136,7 @@ describe(BuildStepGlobalContext, () => {
},
createMockLogger()
);
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
expect(ctx.stepsInternalBuildDirectory).toBe('/m/n/o');
expect(ctx.defaultWorkingDirectory).toBe('/g/h/i');
expect(ctx.runtimePlatform).toBe(BuildRuntimePlatform.DARWIN);
@@ -199,7 +199,7 @@ describe(BuildStepGlobalContext, () => {
const ctx = createGlobalContextMock({
relativeWorkingDirectory: 'apps/mobile',
});
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
const relativeChildCtx = ctx.stepCtx({
relativeWorkingDirectory: 'scripts',
@@ -230,7 +230,7 @@ describe(BuildStepGlobalContext, () => {
projectTargetDirectory: tempDir,
relativeWorkingDirectory: '',
});
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
});
afterEach(() => {
@@ -283,6 +283,9 @@ describe('StepsConfigParser hook construction', () => {
});
expect(orderedDisplayNames(workflow)).toEqual(['Checkout']);
expect(workflow.hooksByAnchorStep.size).toBe(0);
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
'Ignoring "hooks.before_install_node_modules": this build does not run the "install_node_modules" step.'
);
});
it('rejects a hook step using a composite function missing from the catalog with BuildConfigError', async () => {
@@ -855,17 +858,26 @@ describe('StepsConfigParser lazy hook composite loading', () => {
expect(orderedDisplayNames(workflow)).toEqual(['Checkout']);
});
it('ignores unknown and unrun hook keys without warning', async () => {
const workflow = await parseWorkflowAsync({
it('warns for a registered hook key whose anchor never appeared', async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/checkout' }],
hooks: {
before_install_node_modules: [{ run: 'echo never' }],
before_install_node_module: [{ run: 'echo typo' }],
},
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
});
expect(workflow.hooksByAnchorStep.size).toBe(0);
expect(ctx.baseLogger.warn).not.toHaveBeenCalled();
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
'Ignoring "hooks.before_install_node_modules": this build does not run the "install_node_modules" step.'
);
});
it('warns for an unknown hook key', async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/checkout' }],
hooks: { before_install_node_module: [{ run: 'echo typo' }] },
});
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
'Ignoring unknown hook key "before_install_node_module".'
);
});
it('wraps a loader failure in a BuildConfigError naming the before-side hook key', async () => {
+15
View File
@@ -42,3 +42,18 @@ Github action builds a docker image for every PR with any change in `infra/eas-b
### VM template
Github action builds a new vm template for every PR with any change in `infra/eas-build-worker-images/ios/{{ templateName }}`.
### Packaged startup
Existing services run `src/services/worker/dist/main.js`. In the worker archive,
that path points to `bin/bootstrap.cjs`. The bootstrap loads only Node built-ins
and starts `bin/start-worker.sh`. The shell then uses `exec` to start the normal
worker with the original Node binary, options, and arguments.
Put setup that must precede worker imports or Node startup options in the shell.
The bootstrap forwards SIGINT, SIGTERM, IPC messages, and the worker exit status.
It remains the supervised parent process; test service shutdown on both platforms
before deployment. A failure in the startup shell prevents the worker from
loading. Direct execution of `packages/worker/dist/main.js` bypasses this hook.
Run `yarn workspace @expo/worker test:startup` for the process-level checks.
+53
View File
@@ -0,0 +1,53 @@
// This entry point must only load Node built-ins before the startup shell runs.
const { spawn } = require('node:child_process');
const path = require('node:path');
const worker = spawn(
'/bin/bash',
[
path.join(__dirname, 'start-worker.sh'),
process.execPath,
...process.execArgv,
path.join(__dirname, '../dist/main.js'),
...process.argv.slice(2),
],
{ stdio: process.send ? ['inherit', 'inherit', 'inherit', 'ipc'] : 'inherit' }
);
const signals = ['SIGINT', 'SIGTERM'];
for (const signal of signals) {
process.on(signal, () => worker.kill(signal));
}
// Retain the readiness channel used by process managers in development.
if (process.send) {
worker.on('message', message => {
if (process.connected) {
process.send(message, () => {});
}
});
process.on('message', message => {
if (worker.connected) {
worker.send(message, () => {});
}
});
process.on('disconnect', () => {
if (worker.connected) {
worker.disconnect();
}
});
}
worker.on('error', error => {
process.stderr.write(`Worker startup failed: ${error.stack ?? error}\n`, () => process.exit(1));
});
worker.on('exit', (code, signal) => {
if (signal) {
for (const forwarded of signals) {
process.removeAllListeners(forwarded);
}
process.kill(process.pid, signal);
} else {
process.exit(code ?? 1);
}
});
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# Startup setup belongs here, before the normal worker's Node process starts.
exec "$@"
+3 -2
View File
@@ -11,11 +11,12 @@
"compile": "tsc -p tsconfig.build.json",
"prepublishOnly": "npm run build",
"test": "yarn test:unit",
"test:unit": "./resources/jest --config jest.config.unit.ts",
"test:unit": "yarn test:startup && ./resources/jest --config jest.config.unit.ts",
"test:unit:watch": "./resources/jest --config jest.config.unit.ts --watch",
"test:integration": "./resources/jest --runInBand --config jest.config.integration.ts",
"test:integration:watch": "./resources/jest --runInBand --config jest.config.integration.ts --watchAll",
"typecheck": "tsc"
"typecheck": "tsc",
"test:startup": "node --test tests/*.test.cjs"
},
"dependencies": {
"@babel/core": "7.24.7",
+2 -2
View File
@@ -120,10 +120,10 @@ fi
# Create backward-compatible symlink for orchestrator
# The orchestrator expects ./src/services/worker/dist/main.js
# but the new structure has ./packages/worker/dist/main.js
# Route it through the packaged startup wrapper before loading worker modules.
# We're only symlinking the specific file to discover any other legacy path usage
mkdir -p "$target_root_dir/src/services/worker/dist"
ln -s ../../../../packages/worker/dist/main.js "$target_root_dir/src/services/worker/dist/main.js"
ln -s ../../../../packages/worker/bin/bootstrap.cjs "$target_root_dir/src/services/worker/dist/main.js"
tar zcf $OUTPUT_FILE -C $target_root_dir .
+1 -26
View File
@@ -5,7 +5,7 @@ import config from '../config';
import { createBuildContext } from '../context';
import { getBuildEnv } from '../env';
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
import { uploadSourceMapAsync, uploadWorkflowArtifactAsync } from '../upload';
import { uploadSourceMapAsync } from '../upload';
jest.mock('../env', () => ({
getBuildEnv: jest.fn(() => ({
@@ -18,7 +18,6 @@ jest.mock('../runtimeEnvironment', () => ({
}));
jest.mock('../upload', () => ({
...jest.requireActual('../upload'),
uploadWorkflowArtifactAsync: jest.fn(async () => ({ artifactId: 'artifact-id' })),
uploadSourceMapAsync: jest.fn(async () => ({ filename: null })),
uploadWithAnalyticsAsync: jest.fn(async fn => await fn()),
}));
@@ -130,28 +129,4 @@ describe(createBuildContext.name, () => {
logger: childLogger,
});
});
it('uploads named artifacts to workflow storage and returns their ID', async () => {
const ctx = await createBuildContext({
...baseOptions,
job: {
type: Workflow.GENERIC,
secrets: { environmentSecrets: [] },
} as any,
});
const metadata = { kind: 'maestro-report' };
const result = await ctx.uploadArtifact({
artifact: { type: null, name: 'Report', paths: ['/workingdir/report'], metadata },
logger: childLogger,
});
expect(uploadWorkflowArtifactAsync).toHaveBeenCalledWith(ctx, {
artifactPaths: ['/workingdir/report'],
name: 'Report',
metadata,
logger: childLogger,
});
expect(result).toEqual({ artifactId: 'artifact-id' });
expect(ctx.artifacts).toEqual({});
});
});
+100
View File
@@ -0,0 +1,100 @@
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
function archive(t, main, shell) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'worker-startup-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const worker = path.join(root, 'packages/worker');
fs.mkdirSync(path.join(worker, 'dist'), { recursive: true });
fs.cpSync(path.join(__dirname, '../bin'), path.join(worker, 'bin'), { recursive: true });
fs.writeFileSync(path.join(worker, 'dist/main.js'), main);
if (shell) {
fs.writeFileSync(path.join(worker, 'bin/start-worker.sh'), shell);
}
// Exercise the path that existing images run, including Node's symlink resolution.
const legacy = path.join(root, 'src/services/worker/dist/main.js');
fs.mkdirSync(path.dirname(legacy), { recursive: true });
fs.symlinkSync('../../../../packages/worker/bin/bootstrap.cjs', legacy);
return legacy;
}
function launch(t, entry, args = []) {
const child = spawn(process.execPath, [entry, ...args], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
});
t.after(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
}
});
return child;
}
test(
'startup settings reach the worker before its first module runs',
{ timeout: 10000 },
async t => {
const entry = archive(
t,
'process.send({ ready: true, args: process.argv.slice(2), setting: process.env.STARTUP_TEST }); process.on("message", () => process.exit(7));',
'#!/bin/bash\nset -euo pipefail\nexport STARTUP_TEST=installed\nexec "$@"\n'
);
const child = launch(t, entry, ['argument with spaces']);
const exited = once(child, 'exit');
assert.deepEqual((await once(child, 'message'))[0], {
ready: true,
args: ['argument with spaces'],
setting: 'installed',
});
child.send('stop');
assert.deepEqual(await exited, [7, null]);
}
);
for (const signal of ['SIGINT', 'SIGTERM']) {
test(`forwards ${signal} and preserves termination status`, { timeout: 10000 }, async t => {
const entry = archive(t, 'process.send("ready"); setInterval(() => {}, 1000);');
const child = launch(t, entry);
const exited = once(child, 'exit');
await once(child, 'message');
child.kill(signal);
assert.deepEqual(await exited, [null, signal]);
});
}
test('startup failure prevents loading the worker', { timeout: 10000 }, async t => {
const entry = archive(
t,
'process.send("must not start");',
'#!/bin/bash\nset -euo pipefail\nexit 42\nexec "$@"\n'
);
const child = launch(t, entry);
const messages = [];
child.on('message', message => messages.push(message));
assert.deepEqual(await once(child, 'exit'), [42, null]);
assert.deepEqual(messages, []);
});
test('runs without IPC and retains stdout, stderr, and exit code', { timeout: 10000 }, async t => {
const entry = archive(
t,
'console.log("worker output"); console.error("worker error"); process.exit(9);'
);
const child = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout += data;
});
child.stderr.on('data', data => {
stderr += data;
});
assert.deepEqual(await once(child, 'close'), [9, null]);
assert.equal(stdout, 'worker output\n');
assert.equal(stderr, 'worker error\n');
});