Skip to content
10 changes: 9 additions & 1 deletion packages/steps/src/BuildFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from 'assert';

import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStep, BuildStepFunction } from './BuildStep';
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInputProvider } from './BuildStepInput';
Expand Down Expand Up @@ -117,6 +118,7 @@ export class BuildFunction {
shell,
env,
ifCondition,
compositeFunctionScope,
timeoutMs,
}: {
id?: string;
Expand All @@ -126,6 +128,7 @@ export class BuildFunction {
shell?: string;
env?: BuildStepEnv;
ifCondition?: string;
compositeFunctionScope?: BuildStepCompositeFunctionScope;
timeoutMs?: number;
} = {}
): BuildStep {
Expand Down Expand Up @@ -158,9 +161,14 @@ export class BuildFunction {
supportedRuntimePlatforms: this.supportedRuntimePlatforms,
env,
ifCondition,
compositeFunctionScope,
timeoutMs,
__metricsId: this.__metricsId,
__hookId: this.__hookId,
// The declaration does not survive composite function expansion: hooks
// never fire around steps inside a composite function, so an expanded
// step must not carry an anchor mark for any discovery mechanism
// (parse-time or a future runtime scan) to find.
__hookId: compositeFunctionScope === undefined ? this.__hookId : undefined,
});
}
}
169 changes: 127 additions & 42 deletions packages/steps/src/BuildStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from 'path';
import util from 'util';

import { BuildRuntimePlatform } from './BuildRuntimePlatform';
import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope';
import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from './BuildStepInput';
Expand All @@ -23,7 +24,7 @@ import {
} from './BuildTemporaryFiles';
import { BuildStepRuntimeError } from './errors';
import { interpolateJobContext } from './interpolation';
import { evaluateIfCondition } from './utils/jsepEval';
import { evaluateIfCondition as evaluateIfConditionExpression } from './utils/jsepEval';
import { BIN_PATH } from './utils/shell/bin';
import { getShellCommandAndArgs } from './utils/shell/command';
import { spawnAsync } from './utils/shell/spawn';
Expand Down Expand Up @@ -134,6 +135,7 @@ export class BuildStep extends BuildStepOutputAccessor {
public readonly ctx: BuildStepContext;
public readonly stepEnvOverrides: BuildStepEnv;
public readonly ifCondition?: string;
public readonly compositeFunctionScope?: BuildStepCompositeFunctionScope;
public readonly timeoutMs?: number;
public readonly __metricsId?: string;
public readonly __hookId?: HookAnchorId;
Expand Down Expand Up @@ -162,6 +164,7 @@ export class BuildStep extends BuildStepOutputAccessor {
supportedRuntimePlatforms: maybeSupportedRuntimePlatforms,
env,
ifCondition,
compositeFunctionScope,
timeoutMs,
__metricsId,
__hookId,
Expand All @@ -177,6 +180,7 @@ export class BuildStep extends BuildStepOutputAccessor {
supportedRuntimePlatforms?: BuildRuntimePlatform[];
env?: BuildStepEnv;
ifCondition?: string;
compositeFunctionScope?: BuildStepCompositeFunctionScope;
timeoutMs?: number;
__metricsId?: string;
__hookId?: HookAnchorId;
Expand All @@ -197,6 +201,7 @@ export class BuildStep extends BuildStepOutputAccessor {
this.command = command;
this.shell = shell ?? '/bin/bash -eo pipefail';
this.ifCondition = ifCondition;
this.compositeFunctionScope = compositeFunctionScope;
this.timeoutMs = timeoutMs;
this.__metricsId = __metricsId;
this.__hookId = __hookId;
Expand All @@ -212,15 +217,24 @@ export class BuildStep extends BuildStepOutputAccessor {
this.outputsDir = getTemporaryOutputsDirPath(ctx, this.id);
this.envsDir = getTemporaryEnvsDirPath(ctx, this.id);

this.registerSelf(ctx);
}

/**
* Consulted by registerSelf() in the constructor; overrides may only read state
* assigned before that call (outputById, compositeFunctionScope).
*/
public get isCompositeFunctionInternal(): boolean {
return this.compositeFunctionScope !== undefined;
}

protected registerSelf(ctx: BuildStepGlobalContext): void {
ctx.registerStep(this);
}

public async executeAsync(): Promise<void> {
try {
this.ctx.logger.info(
{ marker: BuildStepLogMarker.START_STEP },
`Executing build step "${this.displayName}"`
);
this.logStepStart();
this.status = BuildStepStatus.IN_PROGRESS;

await fs.mkdir(this.outputsDir, { recursive: true });
Expand Down Expand Up @@ -269,10 +283,7 @@ export class BuildStep extends BuildStepOutputAccessor {
await executionPromise;
}

this.ctx.logger.info(
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS },
`Finished build step "${this.displayName}" successfully`
);
this.logStepSuccess();
this.status = BuildStepStatus.SUCCESS;
} catch (err) {
// Downstream error handling relies on real Errors; wrap non-Error
Expand All @@ -283,11 +294,7 @@ export class BuildStep extends BuildStepOutputAccessor {
: new BuildStepRuntimeError(
`Build step "${this.displayName}" threw a non-Error value: ${util.inspect(err)}`
);
this.ctx.logger.error({ err: error });
this.ctx.logger.error(
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL },
`Build step "${this.displayName}" failed`
);
this.logStepFailed(error);
this.status = BuildStepStatus.FAIL;
throw error;
} finally {
Expand All @@ -300,6 +307,7 @@ export class BuildStep extends BuildStepOutputAccessor {
} catch (error) {
// If the step succeeded, we expect the outputs to be collected successfully.
if (this.status === BuildStepStatus.SUCCESS) {
// Rethrow so BuildWorkflow.recordFailure marks the global failure flag.
throw error;
}

Expand All @@ -318,32 +326,86 @@ export class BuildStep extends BuildStepOutputAccessor {
}

public shouldExecuteStep(): boolean {
const hasAnyPreviousStepFailed = this.ctx.global.hasAnyPreviousStepFailed;

if (
this.compositeFunctionScope &&
!this.compositeFunctionScope.isActive(evaluateIfConditionExpression)
) {
return false;
}
if (!this.ifCondition) {
return !hasAnyPreviousStepFailed;
return !this.ctx.global.hasAnyPreviousStepFailed;
}
return this.evaluateIfCondition(this.ifCondition, {
scope: this.compositeFunctionScope,
env: this.getScriptEnv(),
inputs: this.compositeFunctionScope ? {} : this.evaluateOwnStepInputs(),
});
}

return evaluateIfCondition(
this.ifCondition,
this.ctx.global.getIfConditionContext({
inputs:
this.inputs?.reduce(
(acc, input) => {
acc[input.id] = input.getValue({
interpolationContext: this.getInterpolationContext(),
});
return acc;
},
{} as Record<string, unknown>
) ?? {},
env: this.getScriptEnv(),
})
private evaluateOwnStepInputs(): Record<string, unknown> {
return (
this.inputs?.reduce(
(acc, input) => {
acc[input.id] = input.getValue({
interpolationContext: this.getInterpolationContext(),
skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined,
});
return acc;
},
{} as Record<string, unknown>
) ?? {}
);
}

private evaluateIfCondition(
ifCondition: string,
{
scope,
env,
inputs,
}: {
scope: BuildStepCompositeFunctionScope | undefined;
env: BuildStepEnv;
inputs: Record<string, unknown>;
}
): boolean {
const baseContext = this.ctx.global.getIfConditionContext({
inputs,
env,
}) as JobInterpolationContext;
// Overlay the scope last so `inputs`/`steps` come from the composite function while `eas.*` stays global.
const context = scope ? scope.getScopedInterpolationContext(baseContext) : baseContext;
return evaluateIfConditionExpression(ifCondition, context);
}

public skip(): void {
this.status = BuildStepStatus.SKIPPED;
this.logStepSkipped();
}

protected logStepStart(): void {
this.ctx.logger.info(
{ marker: BuildStepLogMarker.START_STEP },
`Executing build step "${this.displayName}"`
);
}

protected logStepSuccess(): void {
this.ctx.logger.info(
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.SUCCESS },
`Finished build step "${this.displayName}" successfully`
);
}

protected logStepFailed(error: Error): void {
this.ctx.logger.error({ err: error });
this.ctx.logger.error(
{ marker: BuildStepLogMarker.END_STEP, result: BuildStepStatus.FAIL },
`Build step "${this.displayName}" failed`
);
}

protected logStepSkipped(): void {
this.ctx.logger.info(
{ marker: BuildStepLogMarker.START_STEP },
'Executing build step "${this.displayName}"'
Expand All @@ -356,10 +418,11 @@ export class BuildStep extends BuildStepOutputAccessor {
}

private getInterpolationContext(): JobInterpolationContext {
return {
const base: JobInterpolationContext = {
...this.ctx.global.getInterpolationContext(),
env: this.getScriptEnv(),
};
return this.compositeFunctionScope?.getScopedInterpolationContext(base) ?? base;
}

private async executeCommandAsync({ signal }: { signal: AbortSignal | null }): Promise<void> {
Expand Down Expand Up @@ -423,7 +486,12 @@ export class BuildStep extends BuildStepOutputAccessor {
inputs: Object.fromEntries(
Object.entries(this.inputById).map(([key, input]) => [
key,
{ value: input.getValue({ interpolationContext: this.getInterpolationContext() }) },
{
value: input.getValue({
interpolationContext: this.getInterpolationContext(),
skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined,
}),
},
])
),
outputs: this.outputById,
Expand All @@ -438,25 +506,42 @@ export class BuildStep extends BuildStepOutputAccessor {
template: string,
inputs?: BuildStepInput[]
): string {
// Actions support only `${{ }}`; a literal `${ steps.x.y }` reaching bash is the accepted
// behavior, so skip legacy output interpolation for composite-function-scoped steps.
const skipLegacyOutputInterpolation = this.compositeFunctionScope !== undefined;
if (!inputs) {
return interpolateWithOutputs(
this.ctx.global.interpolate(template),
path => this.ctx.global.getStepOutputValue(path) ?? ''
);
const interpolatedWithGlobalContext = this.ctx.global.interpolate(template);
return skipLegacyOutputInterpolation
? interpolatedWithGlobalContext
: interpolateWithOutputs(interpolatedWithGlobalContext, path =>
this.getLegacyStepOutputValue(path)
);
}
const vars = inputs.reduce(
(acc, input) => {
const value = input.getValue({ interpolationContext: this.getInterpolationContext() });
const value = input.getValue({
interpolationContext: this.getInterpolationContext(),
skipLegacyOutputInterpolation,
});
acc[input.id] =
typeof value === 'object' ? JSON.stringify(value) : (value?.toString() ?? '');
return acc;
},
{} as Record<string, string>
);
return interpolateWithOutputs(
interpolateWithInputs(this.ctx.global.interpolate(template), vars),
path => this.ctx.global.getStepOutputValue(path) ?? ''
const interpolatedWithInputsAndGlobalContext = interpolateWithInputs(
this.ctx.global.interpolate(template),
vars
);
return skipLegacyOutputInterpolation
? interpolatedWithInputsAndGlobalContext
: interpolateWithOutputs(interpolatedWithInputsAndGlobalContext, path =>
this.getLegacyStepOutputValue(path)
);
}

private getLegacyStepOutputValue(path: string): string {
return this.ctx.global.getStepOutputValue(path) ?? '';
}

private async collectAndValidateOutputsAsync(outputsDir: string): Promise<void> {
Expand Down
Loading
Loading