Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions packages/steps/src/BuildStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import {
} from './BuildTemporaryFiles';
import { BuildStepRuntimeError } from './errors';
import { interpolateJobContext } from './interpolation';
import {
containsUnresolvedTemplateReference,
resolveInterpolatedTarget,
stringifyInterpolatedResult,
stringifyOptionalInterpolatedResult,
} from './utils/compositeFunctionInterpolation';
import { evaluateIfCondition as evaluateIfConditionExpression } from './utils/jsepEval';
import { BIN_PATH } from './utils/shell/bin';
import { getShellCommandAndArgs } from './utils/shell/command';
Expand Down Expand Up @@ -234,6 +240,8 @@ export class BuildStep extends BuildStepOutputAccessor {

public async executeAsync(): Promise<void> {
try {
this.resolveTemplatedWorkingDirectoryIfNeeded();

this.logStepStart();
this.status = BuildStepStatus.IN_PROGRESS;

Expand Down Expand Up @@ -506,7 +514,7 @@ export class BuildStep extends BuildStepOutputAccessor {
template: string,
inputs?: BuildStepInput[]
): string {
// Actions support only `${{ }}`; a literal `${ steps.x.y }` reaching bash is the accepted
// Composite functions 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) {
Expand Down Expand Up @@ -597,8 +605,32 @@ export class BuildStep extends BuildStepOutputAccessor {
});
}

private getInterpolatedEnvOverrides(): BuildStepEnv {
const ownOverrides = this.stepEnvOverrides;
if (!this.compositeFunctionScope) {
return ownOverrides;
}
// Use global env here to avoid recursing through getScriptEnv.
const base: JobInterpolationContext = {
...this.ctx.global.getInterpolationContext(),
env: this.ctx.global.env,
};
// Call-site env uses caller scope; step env uses action scope.
const inheritedEnv = this.compositeFunctionScope.resolveInheritedEnv(base);
const scoped = this.compositeFunctionScope.getScopedInterpolationContext(base);
const ownEnv = Object.fromEntries(
Object.entries(ownOverrides).map(([key, value]) => {
if (typeof value !== 'string' || !containsUnresolvedTemplateReference(value)) {
return [key, value];
}
return [key, stringifyInterpolatedResult(resolveInterpolatedTarget(value, scoped))];
})
);
return { ...inheritedEnv, ...ownEnv };
}

private getScriptEnv(): Record<string, string> {
const effectiveEnv = { ...this.ctx.global.env, ...this.stepEnvOverrides };
const effectiveEnv = { ...this.ctx.global.env, ...this.getInterpolatedEnvOverrides() };
const currentPath = effectiveEnv.PATH ?? process.env.PATH;
const newPath = currentPath ? `${BIN_PATH}:${currentPath}` : BIN_PATH;
return {
Expand All @@ -609,4 +641,24 @@ export class BuildStep extends BuildStepOutputAccessor {
PATH: newPath,
};
}

private resolveTemplatedWorkingDirectoryIfNeeded(): void {
const scope = this.compositeFunctionScope;
if (!scope) {
return;
}
const relativeWorkingDirectory = this.ctx.relativeWorkingDirectory;
if (
relativeWorkingDirectory === undefined ||
!containsUnresolvedTemplateReference(relativeWorkingDirectory)
) {
return;
}
const context = scope.getScopedInterpolationContext({
...this.ctx.global.getInterpolationContext(),
env: this.getScriptEnv(),
});
const resolved = resolveInterpolatedTarget(relativeWorkingDirectory, context);
this.ctx.updateRelativeWorkingDirectory(stringifyOptionalInterpolatedResult(resolved));
}
}
136 changes: 116 additions & 20 deletions packages/steps/src/BuildStepCompositeFunctionScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,69 @@
*
* Composite functions are flattened into the workflow, but inner steps still need composite-function-local semantics:
* `${{ steps.read }}` must mean the inner `read` step, not a workflow step with the same id.
* This class overlays a short-id `steps` view on top of the global interpolation context.
* This class overlays a `steps` view built from the call's own children on top of the global interpolation context.
* success()/failure() use global workflow status (GitHub composite action parity).
*/
import { JobInterpolationContext } from '@expo/eas-build-job';

import type { BuildStepOutputAccessor } from './BuildStep';
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepEnv } from './BuildStepEnv';
import { BuildStepInput } from './BuildStepInput';
import { BuildStepRuntimeError } from './errors';
import {
resolveInterpolatedTarget,
stringifyInterpolatedResult,
} from './utils/compositeFunctionInterpolation';

export type EvaluateIfExpression = (
expression: string,
context: JobInterpolationContext
) => boolean;

type ScopedInterpolationContext = JobInterpolationContext & { inputs: Record<string, unknown> };

export class BuildStepCompositeFunctionScope {
public readonly parent?: BuildStepCompositeFunctionScope;
public readonly env?: BuildStepEnv;
private readonly ctx: BuildStepGlobalContext;
private readonly ifCondition?: string;
private readonly stepIdAliases: Map<string, string>;
private readonly compositeFunctionPath: string;
private readonly inputs: Map<string, BuildStepInput>;
private readonly providedInputKeys: ReadonlySet<string>;
// Filled by the expander after construction; children and scope need each other.
private readonly childrenByLocalId: Map<string, BuildStepOutputAccessor>;
private cachedIsActive?: boolean;
// Detects cycles while resolving input default values.
private readonly resolvingInputs = new Set<string>();

constructor({
ctx,
parent,
ifCondition,
env,
stepIdAliases,
compositeFunctionPath,
inputs,
providedInputKeys,
childrenByLocalId,
}: {
ctx: BuildStepGlobalContext;
parent?: BuildStepCompositeFunctionScope;
ifCondition?: string;
env?: BuildStepEnv;
stepIdAliases: Map<string, string>;
compositeFunctionPath: string;
inputs: Map<string, BuildStepInput>;
providedInputKeys: ReadonlySet<string>;
childrenByLocalId: Map<string, BuildStepOutputAccessor>;
}) {
this.ctx = ctx;
this.parent = parent;
this.ifCondition = ifCondition;
this.env = env;
this.stepIdAliases = stepIdAliases;
this.compositeFunctionPath = compositeFunctionPath;
this.inputs = inputs;
this.providedInputKeys = providedInputKeys;
this.childrenByLocalId = childrenByLocalId;
}

/**
Expand All @@ -57,13 +81,16 @@ export class BuildStepCompositeFunctionScope {
return this.cachedIsActive;
}

// Call-site if uses caller env/steps/status. Input interpolation and inherited env
// template resolution are added when composite function inputs are wired up.
// Call-site if uses caller env/inputs/steps, not expanded inner steps.
private evaluateCallIfCondition(evaluate: EvaluateIfExpression): boolean {
if (!this.ifCondition) {
return !this.ctx.hasAnyPreviousStepFailed;
}
const env = { ...this.ctx.env, ...(this.env ?? {}) };
const callerBase: JobInterpolationContext = {
...this.ctx.getInterpolationContext(),
env: this.ctx.env,
};
const env = { ...this.ctx.env, ...this.resolveInheritedEnv(callerBase) };
const baseContext = this.ctx.getIfConditionContext({
inputs: {},
env,
Expand All @@ -74,27 +101,96 @@ export class BuildStepCompositeFunctionScope {
return evaluate(this.ifCondition, context);
}

public resolveStepId(shortId: string): string | undefined {
return this.stepIdAliases.get(shortId);
}

public getScopedInterpolationContext(base: JobInterpolationContext): JobInterpolationContext {
return {
// Spread `base` into a new object; never spread the returned context, as that would eagerly
// resolve every lazy input getter.
const scoped: ScopedInterpolationContext = {
...base,
steps: this.buildStepsView(),
inputs: this.buildInputsObject(base),
};
return scoped;
}

/** Caller scope for `with:` values and call-site env. */
public getCallerInterpolationContext(base: JobInterpolationContext): JobInterpolationContext {
return this.parent ? this.parent.getScopedInterpolationContext(base) : base;
}

private resolveScopeEnv(base: JobInterpolationContext, parentEnv: BuildStepEnv): BuildStepEnv {
if (!this.env) {
return {};
}
const callerBase: JobInterpolationContext = { ...base, env: { ...base.env, ...parentEnv } };
const callerContext = this.getCallerInterpolationContext(callerBase);
return Object.fromEntries(
Object.entries(this.env).map(([key, value]) => [
key,
stringifyInterpolatedResult(resolveInterpolatedTarget(value, callerContext)),
])
);
}

/** Merge call-site env from each scope level, outer to inner; inner keys win. */
public resolveInheritedEnv(base: JobInterpolationContext): BuildStepEnv {
const parentEnv = this.parent?.resolveInheritedEnv(base) ?? {};
return { ...parentEnv, ...this.resolveScopeEnv(base, parentEnv) };
}

// Workflow hides prefixed ids; re-expose them under short aliases.
// Workflow hides prefixed ids; re-expose the call's children under their local ids.
private buildStepsView(): JobInterpolationContext['steps'] {
const fullSteps = this.ctx.getFullStepsInterpolationView();
const view: JobInterpolationContext['steps'] = {};
for (const [shortId, prefixedId] of this.stepIdAliases) {
const step = fullSteps[prefixedId];
if (step !== undefined) {
view[shortId] = step;
}
for (const [localId, child] of this.childrenByLocalId) {
view[localId] = {
outputs: Object.fromEntries(child.outputs.map(output => [output.id, output.rawValue])),
};
}
return view;
}

private buildInputsObject(base: JobInterpolationContext): Record<string, unknown> {
const inputs: Record<string, unknown> = {};
for (const [name, input] of this.inputs) {
Object.defineProperty(inputs, name, {
enumerable: true,
configurable: true,
get: () => this.resolveInputValue(name, input, base),
});
}
return inputs;
}

private resolveInputValue(
name: string,
input: BuildStepInput,
base: JobInterpolationContext
): unknown {
if (this.resolvingInputs.has(name)) {
throw new BuildStepRuntimeError(
`Composite function "${this.compositeFunctionPath}" input "${name}" references itself, directly or indirectly, through its default value.`
);
}
this.resolvingInputs.add(name);
try {
const isProvided = this.providedInputKeys.has(name);
// Env binds at the call boundary so inner step `env:` overrides do not leak into inputs.
const boundBase: JobInterpolationContext = {
...base,
env: this.resolveCompositeFunctionBoundaryEnv(base),
};
// Caller-provided values resolve in the caller's scope; defaults resolve in this composite function's.
const interpolationContext = isProvided
? this.getCallerInterpolationContext(boundBase)
: this.getScopedInterpolationContext(boundBase);
return input.getValue({ interpolationContext, skipLegacyOutputInterpolation: true });
} finally {
this.resolvingInputs.delete(name);
}
}

// Global + inherited call-site env; inputs ignore inner overrides.
public resolveCompositeFunctionBoundaryEnv(base: JobInterpolationContext): BuildStepEnv {
const boundaryBase: JobInterpolationContext = { ...base, env: this.ctx.env };
return { ...this.ctx.env, ...this.resolveInheritedEnv(boundaryBase) };
}
}
27 changes: 13 additions & 14 deletions packages/steps/src/BuildStepContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,23 +95,14 @@ export class BuildStepGlobalContext {
public get staticContext(): StaticJobInterpolationContext {
return {
...this.provider.staticContext(),
steps: this.buildStepsInterpolationMap({ includeInternal: false }),
steps: this.buildStepsInterpolationMap(),
};
}

/** Includes internal ids for {@link BuildStepCompositeFunctionScope}; workflow uses {@link staticContext}. */
public getFullStepsInterpolationView(): StaticJobInterpolationContext['steps'] {
return this.buildStepsInterpolationMap({ includeInternal: true });
}

private buildStepsInterpolationMap({
includeInternal,
}: {
includeInternal: boolean;
}): StaticJobInterpolationContext['steps'] {
private buildStepsInterpolationMap(): StaticJobInterpolationContext['steps'] {
return Object.fromEntries(
Object.values(this.stepById)
.filter(step => includeInternal || !this.internalStepIds.has(step.id))
.filter(step => !this.internalStepIds.has(step.id))
.map(step => [
step.id,
{
Expand Down Expand Up @@ -328,7 +319,7 @@ export interface SerializedBuildStepContext {

export class BuildStepContext {
public readonly logger: bunyan;
public readonly relativeWorkingDirectory?: string;
private _relativeWorkingDirectory?: string;

constructor(
private readonly ctx: BuildStepGlobalContext,
Expand All @@ -341,7 +332,15 @@ export class BuildStepContext {
}
) {
this.logger = logger ?? ctx.baseLogger;
this.relativeWorkingDirectory = relativeWorkingDirectory;
this._relativeWorkingDirectory = relativeWorkingDirectory;
}

public get relativeWorkingDirectory(): string | undefined {
return this._relativeWorkingDirectory;
}

public updateRelativeWorkingDirectory(value: string | undefined): void {
this._relativeWorkingDirectory = value;
}

public get global(): BuildStepGlobalContext {
Expand Down
19 changes: 19 additions & 0 deletions packages/steps/src/BuildStepInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,25 @@ export class BuildStepInput<
}
}

/** Raw allowed-values check; skips step/context refs that only resolve at runtime. */
export function getDisallowedInputValueError(
input: BuildStepInput,
stepDisplayName: string
): string | undefined {
if (input.rawValue === undefined) {
return undefined;
}
if (input.isRawValueStepOrContextReference() || input.isRawValueOneOfAllowedValues()) {
return undefined;
}
const rendered =
typeof input.rawValue === 'object' ? JSON.stringify(input.rawValue) : String(input.rawValue);
const allowedValues = (input.allowedValues ?? [])
.map(value => (typeof value === 'object' ? JSON.stringify(value) : `"${value}"`))
.join(', ');
return `Input parameter "${input.id}" for step "${stepDisplayName}" is set to "${rendered}" which is not one of the allowed values: ${allowedValues}.`;
}

export function makeBuildStepInputByIdMap(inputs?: BuildStepInput[]): BuildStepInputById {
if (inputs === undefined) {
return {};
Expand Down
Loading
Loading