From b1fc5efd34b99f1de06ea6a6c38f5d90a0c92e56 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 19:09:41 +0100 Subject: [PATCH 1/2] feat(github-actions): read an action reference and composite action metadata parseActionReference reads a uses value into its forms: a local ./path, whose .. segments are kept for the base it appears in to apply, and an owner/repo, owner/repo@ref or subpath reference. Anything else, a docker:// image and the gated $/ alias among them, is rejected naming what the value is. compositeActionSchema validates the metadata of an action whose runs.using is composite: the declared inputs, the run and uses steps with the keys GitHub documents for each, and branding and runs.outputs carried as data. Keys GitHub does not document for a composite action are rejected by the strict objects. Sources: GitHub's metadata syntax documentation and the actions/runner source for what its parser accepts. --- .../pipeline-github-actions/src/action.ts | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 packages/pipeline-github-actions/src/action.ts diff --git a/packages/pipeline-github-actions/src/action.ts b/packages/pipeline-github-actions/src/action.ts new file mode 100644 index 0000000..7b63bd4 --- /dev/null +++ b/packages/pipeline-github-actions/src/action.ts @@ -0,0 +1,249 @@ +import { jsonPointerOf } from "@pipewright/pipeline-contract"; +import { z } from "zod"; + +/** + * The metadata file of an action (`action.yml`, or `action.yaml`), read as data. A fetched tree is never executed: this schema describes what a composite action declares, and the interpretation in `composite.ts` is the only place that turns it into steps. + * @see https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions + * @see https://web.archive.org/web/20250615225859/https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions + */ + +/** + * A value a workflow or a composite step can write where GitHub reads a string: a scalar, because the YAML forms of these keys are scalars and anything else (a mapping, a list, a null) is a malformed file, not a value to coerce. + */ +const scalarSchema = z.union([z.string(), z.number(), z.boolean()]); + +const envSchema = z.record(z.string().min(1), scalarSchema); + +/** An input id: GitHub requires a letter or underscore first, then letters, digits, hyphens and underscores. */ +const INPUT_ID = /^[A-Za-z_][A-Za-z0-9_-]*$/; + +/** + * One declared input. GitHub documents no type for an action's inputs (unlike a reusable workflow's `workflow_call` inputs): every value an action receives is a string, and `default` is a string or a YAML scalar that reads as one. `required` is a request, not an enforcement: the runner supplies an empty string when a required input arrives unset, so enforcement is this package's decision, taken in `composite.ts`. + */ +const inputSchema = z.strictObject({ + description: z.string(), + required: z.boolean().optional(), + default: scalarSchema.optional(), + deprecationMessage: z.string().optional(), +}); + +/** + * A `continue-on-error` value: a boolean, or the text of an expression that evaluates to one. + */ +const continueOnErrorSchema = z.union([z.boolean(), z.string()]); + +/** + * A step that runs a command. `shell` is required whenever `run` is written, which is GitHub's own rule for composite steps and the reason it is not optional here. + */ +const runStepSchema = z.strictObject({ + name: z.string().optional(), + id: z.string().optional(), + if: z.string().optional(), + run: z.string().min(1), + shell: z.string().min(1), + "working-directory": z.string().min(1).optional(), + env: envSchema.optional(), + "continue-on-error": continueOnErrorSchema.optional(), +}); + +/** + * A step that calls another action. `working-directory` is not a key of this form: GitHub documents it for the command a `run` step executes. + */ +const usesStepSchema = z.strictObject({ + name: z.string().optional(), + id: z.string().optional(), + if: z.string().optional(), + uses: z.string().min(1), + with: z.record(z.string(), scalarSchema).optional(), + env: envSchema.optional(), + "continue-on-error": continueOnErrorSchema.optional(), +}); + +/** + * A composite step: exactly one of `run` and `uses`. The two schemas share no field that the other forbids, so the union accepts a step when precisely one key is present and rejects it, on both branches, when both or neither is. + */ +export const compositeStepSchema = z.union([runStepSchema, usesStepSchema]); +export type CompositeStep = z.output; + +/** + * The metadata of an action whose `runs.using` is `composite`. The keys GitHub documents and this package does not read (`branding`, the declared `inputs`' descriptions, `runs.outputs`) are carried as data, because they describe the action rather than change how its steps run. Keys GitHub does not document for a composite action (a step `timeout-minutes`, `runs.image`) are rejected by the strict objects, which is the same stance the importer takes with a workflow. + */ +export const compositeActionSchema = z.strictObject({ + name: z.string().min(1), + description: z.string(), + inputs: z.record(z.string().regex(INPUT_ID), inputSchema).optional(), + branding: z.strictObject({ icon: z.string(), color: z.string() }).optional(), + runs: z.strictObject({ + using: z.literal("composite"), + steps: z.array(compositeStepSchema).min(1), + /** + * The action's outputs, each a `${{ }}` expression over the steps that produced them. They are carried as data only: evaluating one needs the outputs those steps wrote at run time, which no expansion of the steps can know beforehand. + */ + outputs: z + .record( + z.string().min(1), + z.strictObject({ value: z.string(), description: z.string().optional() }), + ) + .optional(), + }), +}); +export type CompositeAction = z.output; + +/** Where a metadata file's node is wrong, and what is wrong with it. */ +export interface MetadataIssue { + readonly pointer: string; + + readonly message: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function issuesOf(error: z.ZodError): readonly MetadataIssue[] { + return error.issues.map((issue) => ({ + pointer: jsonPointerOf( + issue.path.filter((segment): segment is string | number => typeof segment !== "symbol"), + ), + message: issue.message, + })); +} + +/** + * Validates one composite step, choosing its branch by the key that is present. Validating through the union instead would report a failure on both branches for any one wrong key, because each branch forbids the other's key, so this is how a step's own keys are checked and how the union is read back. + */ +export function parseCompositeStep( + value: unknown, +): + | { readonly success: true; readonly step: CompositeStep } + | { readonly success: false; readonly issues: readonly MetadataIssue[] } { + if (isRecord(value)) { + if ("run" in value && "uses" in value) { + return { + success: false, + issues: [{ pointer: "", message: "a step that has both run and uses" }], + }; + } + if ("run" in value) { + const parsed = runStepSchema.safeParse(value); + + return parsed.success + ? { success: true, step: parsed.data } + : { success: false, issues: issuesOf(parsed.error) }; + } + if ("uses" in value) { + const parsed = usesStepSchema.safeParse(value); + + return parsed.success + ? { success: true, step: parsed.data } + : { success: false, issues: issuesOf(parsed.error) }; + } + + return { + success: false, + issues: [{ pointer: "", message: "a step with neither run nor uses" }], + }; + } + + return { success: false, issues: [{ pointer: "", message: "a step that is not a mapping" }] }; +} + +/** + * Raised when a `uses` value is not one of the forms a reference can take. The expansion path wraps this into `CompositeActionError`; the message stands alone when the parser is used directly. + */ +export class ActionReferenceError extends Error { + /** The `uses` value as written. */ + readonly uses: string; + + /** What the value is, phrased like the importer's own rejections. */ + readonly construct: string; + + constructor(uses: string, construct: string) { + super(`cannot resolve the reference ${JSON.stringify(uses)}: ${construct}`); + this.name = "ActionReferenceError"; + this.uses = uses; + this.construct = construct; + } +} + +/** + * A `uses` value. A local path is resolved against the directory it appears in, which for a workflow step is the repository the runner checked out and for a composite step is the action's own directory, so its `..` segments are kept for whoever knows that base to apply; a remote reference names a repository, optionally a directory inside it, and optionally the ref to read it at, exactly as written because a missing ref is the host resolver's to interpret, not this parser's. + */ +export type ActionReference = + | { readonly kind: "local"; readonly path: string } + | { + readonly kind: "remote"; + readonly owner: string; + readonly repo: string; + readonly subpath: string | undefined; + readonly ref: string | undefined; + }; + +/** + * Removes `.` and empty segments and keeps `..` as written, because a local reference's `..` is resolved against the directory the reference appears in, which the parser does not know: a workflow step's local reference is relative to the repository root and a composite step's to the action's own directory, and only the code that knows the base can tell a climb within the repository from one out of it. + */ +function normalisePath(segments: readonly string[], uses: string): string[] { + if (segments.some((segment) => segment === "")) { + throw new ActionReferenceError(uses, "a path with an empty segment"); + } + + return segments.filter((segment) => segment !== "."); +} + +/** The characters a GitHub owner or repository name is built from, so a locator segment carrying anything else is named rather than fetched. */ +const NAME = /^[A-Za-z0-9_.-]+$/; + +/** + * Reads a `uses` value into its forms: a local `./path`, an `owner/repo`, an `owner/repo@ref` and a subpath form `owner/repo/path@ref`. The first `@` separates the locator from the ref, because an owner, a repository and a path cannot contain one while a ref may (a branch or tag name can). Anything else, a `docker://` image and the gated same-repository `$/` alias among them, is rejected naming what the value is. + * @see https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions + */ +export function parseActionReference(uses: string): ActionReference { + if (uses.startsWith("./")) { + const segments = normalisePath(uses.slice(2).split("/"), uses); + if (segments.length === 0) { + throw new ActionReferenceError(uses, "a local reference with no path after ./"); + } + + return { kind: "local", path: segments.join("/") }; + } + if (uses.startsWith("docker://")) { + throw new ActionReferenceError( + uses, + "a container image, which needs a container runtime; container actions are out of scope on the host", + ); + } + if (uses.startsWith("$/")) { + throw new ActionReferenceError( + uses, + "the same-repository alias, which is a gated runner feature this package does not resolve; write the repository reference out", + ); + } + const at = uses.indexOf("@"); + const [locator, ref] = at === -1 ? [uses] : [uses.slice(0, at), uses.slice(at + 1)]; + if (ref !== undefined && ref === "") { + throw new ActionReferenceError(uses, "a remote reference with no ref after @"); + } + const [owner, repo, ...subpath] = locator.split("/"); + if (owner === undefined || repo === undefined || !NAME.test(owner) || !NAME.test(repo)) { + throw new ActionReferenceError( + uses, + "a remote reference that is not owner/repo, optionally a directory inside it, optionally @ref", + ); + } + const directory = normalisePath(subpath, uses); + let level = 0; + for (const segment of directory) { + level += segment === ".." ? -1 : 1; + if (level < 0) { + throw new ActionReferenceError(uses, "a path that climbs above the repository it names"); + } + } + + return { + kind: "remote", + owner, + repo, + subpath: directory.length === 0 ? undefined : directory.join("/"), + ref, + }; +} From bc47b6bd43656b6e95f6010031b2d53e41d9b249 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 19:10:16 +0100 Subject: [PATCH 2/2] feat(github-actions): expand a composite action into neutral steps expandCompositeAction takes the provider step the importer builds for a uses: reference, resolves the action through an injected port, and returns the run steps and nested provider steps the model runs. Every declared input is mapped from the supplied with or its default, with input names matched without regard to case, and the template expressions of with values, defaults and the action's steps are evaluated through the expression evaluator against a context whose inputs holds the mapped values and whose github.action_path is the directory the host resolved the action to. Each expanded run step carries its own evaluated environment plus GITHUB_ACTION_PATH, and a nested action that is not composite stays a provider step carrying the text as written. Nesting recurses to the ten actions GitHub's own runner allows, and a cycle is rejected naming the whole reference chain. An undeclared input, a required input with no value, a shell or continue-on-error the model has no step form for and metadata that does not match the schema are all rejected naming what and where, the metadata by file and JSON pointer. Refs #74; wiring the expansion into the runner is #76. --- .../pipeline-github-actions/src/composite.ts | 580 ++++++++++++++++++ packages/pipeline-github-actions/src/index.ts | 17 + .../test/composite.unit.test.ts | 556 +++++++++++++++++ 3 files changed, 1153 insertions(+) create mode 100644 packages/pipeline-github-actions/src/composite.ts create mode 100644 packages/pipeline-github-actions/test/composite.unit.test.ts diff --git a/packages/pipeline-github-actions/src/composite.ts b/packages/pipeline-github-actions/src/composite.ts new file mode 100644 index 0000000..53442ef --- /dev/null +++ b/packages/pipeline-github-actions/src/composite.ts @@ -0,0 +1,580 @@ +import { jsonPointerOf, type Step } from "@pipewright/pipeline-contract"; +import { readYaml } from "@pipewright/yaml-emit"; + +import { + compositeActionSchema, + parseActionReference, + parseCompositeStep, + type ActionReference, + type CompositeAction, + type CompositeStep, + type MetadataIssue, +} from "./action.ts"; +import { evaluateCondition, evaluateTemplate, type EvaluationContext } from "./evaluation.ts"; +import { PROVIDER } from "./vocabulary.ts"; + +/** + * The deepest stack of composite actions one step may expand. GitHub's own runner prepares actions recursively and throws once the nesting depth passes nine, counted from zero at the workflow's own steps, which allows ten nested actions; this expansion counts the action the step names as the first of those ten and rejects an eleventh, so a workflow GitHub runs expands here too. + * @see https://github.com/actions/runner/blob/main/src/Runner.Common/Constants.cs, the constant CompositeActionsMaxDepth + */ +export const COMPOSITE_ACTION_MAX_DEPTH = 10; + +/** Where a failure is, when it is at a node of a metadata file. */ +interface Location { + readonly file: string; + + readonly pointer: string | undefined; +} + +/** Reports a construct that cannot be expanded, where it is when it is in a metadata file. */ +type Fail = (construct: string, at?: Location) => never; + +/** + * Raised when a composite action cannot be expanded: a reference that is not one of the forms a `uses` value can take, metadata that does not match what a composite action declares, a construct the neutral model has no step form for, a cycle, or nesting past the bound. The chain names every reference from the step's own `uses` to the action the failure is in, so a failure deep inside a fetched action can still be located. + */ +export class CompositeActionError extends Error { + /** Every reference the expansion followed, by resolved location, ending with the one the failure is in; a cycle ends by repeating where it closed. Empty when the `uses` value itself could not be read. */ + readonly chain: readonly string[]; + + /** The metadata file the failure is in, when it is in one. */ + readonly file: string | undefined; + + /** A JSON pointer into the metadata file, when the failure is at a node of one. */ + readonly pointer: string | undefined; + + constructor(chain: readonly string[], construct: string, at?: Location) { + const where = + at === undefined ? "" : ` at ${at.file}${at.pointer === undefined ? "" : ` ${at.pointer}`}`; + const through = chain.length < 2 ? "" : `, through ${chain.join(" -> ")}`; + + super(`cannot expand the composite action: ${construct}${where}${through}`); + this.name = "CompositeActionError"; + this.chain = chain; + this.file = at?.file; + this.pointer = at?.pointer; + } +} + +/** + * One action's files, fetched at the reference the host resolved them from. A tree is data: reading it decides what runs, and nothing in it executes because it was fetched. + */ +export interface ActionTree { + /** The directory the host materialised the action at, which is `github.action_path` for the action's steps and the base of relative paths in its commands. */ + readonly path: string; + + /** Reads a file of the tree relative to the action's own directory, or undefined when the tree holds no file of that name. */ + readonly read: (path: string) => Promise; + + /** Resolves a local `./` reference, which GitHub reads relative to the action's own directory, to the tree of the action it names. */ + readonly local: (path: string) => Promise; +} + +/** + * The host's resolution port: from a reference to the action's files. The library makes no request of its own; a CLI host resolves a local reference against the workspace and a remote one at its ref, serving a reference to the repository being run from the checkout rather than the network, and tests serve in-memory trees. + */ +export type ActionResolver = ( + reference: ActionReference, + signal?: AbortSignal, +) => Promise; + +export interface ExpandOptions { + /** The host's resolution port. */ + readonly resolve: ActionResolver; + + /** + * The context the expressions of the action's steps are evaluated against, with `inputs` replaced by the action's mapped inputs for those steps. A condition that reads the outcome of a step the expansion itself produces reads this caller's view, because outcomes are known at run time only. + */ + readonly context: EvaluationContext; + + readonly signal?: AbortSignal; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isScalar(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function scalarText(value: string | number | boolean): string { + return typeof value === "string" ? value : String(value); +} + +/** The repository half of a remote reference's identity, as written. */ +function repoOf(reference: Extract): string { + const base = `${reference.owner}/${reference.repo}`; + + return reference.ref === undefined ? base : `${base}@${reference.ref}`; +} + +/** + * The canonical location of an action: how a chain names it and how a cycle is recognised. A local reference resolved into the workspace is named by its path from there, and a remote one by its repository at its ref plus its directory inside it, so the same action reached twice is the same string and two actions that differ only by ref are not. + */ +function identityOf(repo: string, at: string): string { + if (repo === "") return `./${at}`; + + return at === "" ? repo : `${repo}/${at}`; +} + +/** + * Resolves a local reference's path against the location it appears at, rejecting one that climbs above the tree that location is in, because a local action cannot name an action outside the repository or workspace its own action was resolved from. + */ +function resolveAt(base: string, path: string, fail: (construct: string) => never): string { + const resolved: string[] = []; + const segments = [...(base === "" ? [] : base.split("/")), ...path.split("/")]; + for (const segment of segments) { + if (segment === "..") { + if (resolved.pop() === undefined) { + fail("a local reference that climbs above the tree it was resolved from"); + } + continue; + } + resolved.push(segment); + } + + return resolved.join("/"); +} + +/** + * The shells whose text the host's executor runs as written. The neutral `run` step has no shell field and every host of this tool executes a step's command through one POSIX shell of its own, so `bash` and `sh` need no interpretation while `pwsh`, `powershell`, `cmd` and `python` name interpreters the model cannot ask for and are rejected rather than mis-run. + */ +const POSIX_SHELLS = new Set(["bash", "sh"]); + +/** What a metadata file said, split on the one key that decides how its action runs. */ +interface Metadata { + readonly using: string; + + readonly document: Record; + + readonly file: string; +} + +/** + * Reads a metadata file. GitHub reads the file under either of two names and prefers `action.yml`, so the second is a documented alternative read only when the first is absent, not an invented fallback. + */ +async function readMetadata(tree: ActionTree, identity: string, fail: Fail): Promise { + const yml = await tree.read("action.yml"); + const yaml = yml === undefined ? await tree.read("action.yaml") : undefined; + const text = yml ?? yaml; + if (text === undefined) { + return fail(`a tree with no action.yml or action.yaml, at ${identity}`); + } + const file = `${identity}/${yml === undefined ? "action.yaml" : "action.yml"}`; + let value: unknown; + try { + value = readYaml(text).value; + } catch (error) { + fail( + `a metadata file that is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, + { file, pointer: undefined }, + ); + } + if (!isRecord(value)) fail("a metadata file that is not a mapping", { file, pointer: undefined }); + const runs = value.runs; + if (!isRecord(runs)) fail("metadata with no runs mapping", { file, pointer: "/runs" }); + if (typeof runs.using !== "string") { + fail("a runs.using that is not text", { file, pointer: "/runs/using" }); + } + + return { using: runs.using, document: value, file }; +} + +/** + * Validates a metadata document as a composite action. Each step is validated through its own branch, so a wrong key is named where it is; the document is then validated as a whole for everything around the steps. + */ +function compositeOf(metadata: Metadata, fail: Fail): CompositeAction { + const { document, file } = metadata; + const runs = document.runs; + const steps = isRecord(runs) && Array.isArray(runs.steps) ? runs.steps : []; + for (const [index, raw] of steps.entries()) { + const parsed = parseCompositeStep(raw); + if (parsed.success) continue; + const prefix = `/runs/steps/${String(index)}`; + const issues = parsed.issues.map((issue) => ({ + pointer: `${prefix}${issue.pointer}`, + message: issue.message, + })); + + fail(described(issues), { file, pointer: issues[0]?.pointer ?? prefix }); + } + const parsed = compositeActionSchema.safeParse(document); + if (!parsed.success) { + const issues = parsed.error.issues.map((issue) => ({ + pointer: jsonPointerOf( + issue.path.filter((segment): segment is string | number => typeof segment !== "symbol"), + ), + message: issue.message, + })); + + fail(described(issues), { file, pointer: issues[0]?.pointer }); + } + + return parsed.data; +} + +/** The failure text for a metadata document: every issue as a pointer and a message. */ +function described(issues: readonly MetadataIssue[]): string { + return `metadata that does not match a composite action: ${issues + .map((issue) => `${issue.pointer} ${issue.message}`) + .join("; ")}`; +} + +/** + * Maps the `with` values a caller supplied onto the inputs the action declares. GitHub matches input names without regard to case and supplies an empty string for an optional input that arrived unset, which the runner itself writes in as the implicit default, so both are done here. Two things GitHub tolerates are errors instead, because each one means the workflow and the action disagree: an input the action does not declare (GitHub logs a warning and drops it), and a required input with no supplied value and no default (GitHub passes the empty string the runner substituted). Defaults are evaluated against the calling context rather than the action's own inputs, because that is the view the runner evaluates them with, before the child's inputs context exists. + * @see https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionRunner.cs, where the step's inputs are evaluated and the defaults merged + */ +function mapInputs( + action: CompositeAction, + supplied: Readonly>, + ambient: EvaluationContext, + fail: (construct: string) => never, +): Record { + const declared = action.inputs ?? {}; + const mapped: Record = {}; + const matched = new Set(); + for (const [name, input] of Object.entries(declared)) { + const sought = name.toLowerCase(); + let value: string | undefined; + for (const [key, entry] of Object.entries(supplied)) { + if (key.toLowerCase() !== sought) continue; + matched.add(key); + // The first matching key in document order wins when two differ only by case, which is the closest a mapping without duplicate keys can come to the runner's dictionary. + value ??= entry; + } + if (value === undefined && input.default !== undefined) { + value = evaluateTemplate(scalarText(input.default), ambient); + } + if (value === undefined && input.required === true) { + fail(`the required input "${name}", which no with supplied and which declares no default`); + } + mapped[name] = value ?? ""; + } + for (const key of Object.keys(supplied)) { + if (!matched.has(key)) fail(`the input "${key}", which the action does not declare`); + } + + return mapped; +} + +/** One nesting level to expand: where the action is, everything its step carried, and the chain walked so far. */ +interface Frame { + /** The action's file space: a remote repository at the ref it was read at, or empty for the workspace a local reference resolved into. */ + readonly repo: string; + + /** The action's directory within its file space, normalised, empty at the root. */ + readonly at: string; + + /** The `uses` text as written, which a provider step carries verbatim when the action turns out not to be composite. */ + readonly uses: string; + + /** The `with` mapping as written, whose values a provider step carries verbatim. */ + readonly with: Readonly>; + + /** The step's name, evaluated where the step appears, which a provider step carries. */ + readonly name: string | undefined; + + readonly ambient: EvaluationContext; + + readonly chain: readonly string[]; + + readonly depth: number; + + /** Whether this frame is a step inside another composite action, whose non-composite action stays a provider step instead of failing the expansion. */ + readonly nested: boolean; + + readonly treeOf: (signal: AbortSignal | undefined) => Promise; +} + +/** The step a nested `uses` becomes when the action it names is not one this host interprets: the text as written, which is how the importer carries a workflow's own `uses` steps. */ +function providerStepOf( + uses: string, + withMapping: Readonly>, + name: string | undefined, +): Step { + return { + kind: "provider", + provider: PROVIDER, + native: { + uses, + ...(Object.keys(withMapping).length === 0 ? {} : { with: withMapping }), + }, + ...(name === undefined ? {} : { name }), + }; +} + +async function expandFrame(frame: Frame, options: ExpandOptions): Promise { + const { ambient, chain, depth } = frame; + options.signal?.throwIfAborted(); + const identity = identityOf(frame.repo, frame.at); + const through = [...chain, identity]; + const fail: Fail = (construct, at) => { + throw new CompositeActionError(through, construct, at); + }; + if (chain.includes(identity)) { + return fail(`the reference ${identity}, which is already being expanded`); + } + if (depth > COMPOSITE_ACTION_MAX_DEPTH) { + return fail( + `nesting ${String(depth)} composite actions deep, where the bound is ${String(COMPOSITE_ACTION_MAX_DEPTH)}`, + ); + } + + const tree = await frame.treeOf(options.signal); + const metadata = await readMetadata(tree, identity, fail); + if (metadata.using !== "composite") { + if (frame.nested) return [providerStepOf(frame.uses, frame.with, frame.name)]; + + return fail(`runs.using "${metadata.using}", which is not "composite"`, { + file: metadata.file, + pointer: "/runs/using", + }); + } + const action = compositeOf(metadata, fail); + + // A `with` value is evaluated where its step appears, before the action's own inputs exist, and every mapped value is a string because the runner builds the child's inputs context from strings however the caller wrote the value. + const supplied: Record = {}; + for (const [key, value] of Object.entries(frame.with)) { + supplied[key] = evaluateTemplate(scalarText(value), ambient); + } + const inputs = mapInputs(action, supplied, ambient, fail); + const ofAction: EvaluationContext = { + ...ambient, + github: { ...ambient.github, action_path: tree.path }, + inputs, + }; + + // The steps expand in the order the action declared them, each through the context the ones before it leave behind, so the awaits chain rather than run side by side. + return action.runs.steps.reduce>(async (previous, step, index) => { + const steps = await previous; + options.signal?.throwIfAborted(); + const stepAt = (key: string): string => `/runs/steps/${String(index)}/${key}`; + const stepEnv: Record = {}; + for (const [key, value] of Object.entries(step.env ?? {})) { + stepEnv[key] = evaluateTemplate(scalarText(value), ofAction); + } + // The step's own environment is visible to its other expressions, so those evaluate against the merged view. + const stepContext: EvaluationContext = + Object.keys(stepEnv).length === 0 + ? ofAction + : { ...ofAction, env: { ...ofAction.env, ...stepEnv } }; + const name = step.name === undefined ? undefined : evaluateTemplate(step.name, stepContext); + if (step.if !== undefined && !evaluateCondition(step.if, stepContext)) return steps; + const continueOnError = step["continue-on-error"]; + if (continueOnError !== undefined) { + const setting = + typeof continueOnError === "string" + ? evaluateTemplate(continueOnError, stepContext) + : String(continueOnError); + if (setting !== "false") { + return fail("a step with continue-on-error, which the model has no step form for", { + file: metadata.file, + pointer: stepAt("continue-on-error"), + }); + } + } + if ("run" in step) { + const shell = evaluateTemplate(step.shell, stepContext); + if (!POSIX_SHELLS.has(shell)) { + return fail( + `the shell "${shell}", which names an interpreter the host's own shell would not run as written`, + { file: metadata.file, pointer: stepAt("shell") }, + ); + } + const run = evaluateTemplate(step.run, stepContext); + if (run === "") { + return fail("a command that evaluated to the empty string", { + file: metadata.file, + pointer: stepAt("run"), + }); + } + + return [ + ...steps, + { + kind: "run", + ...(name === undefined ? {} : { name }), + run, + // The runner sets GITHUB_ACTION_PATH while an action's commands execute, so it is injected over any value the step itself wrote for it. + env: { ...stepEnv, GITHUB_ACTION_PATH: tree.path }, + ...(step["working-directory"] === undefined + ? {} + : { workingDirectory: evaluateTemplate(step["working-directory"], stepContext) }), + } satisfies Step, + ]; + } + + return [ + ...steps, + ...(await expandNestedUses(step, { + ofAction: stepContext, + name, + through, + depth, + frame, + tree, + metadata, + index, + options, + })), + ]; + }, Promise.resolve([])); +} + +/** What a nested `uses` step needs from the loop that read it. */ +interface NestedCall { + /** The context of the enclosing action's step, with the step's own environment merged in. */ + readonly ofAction: EvaluationContext; + + readonly name: string | undefined; + + /** The chain ending at the enclosing action, which the child's own frame extends. */ + readonly through: readonly string[]; + + /** The enclosing action's nesting level; the child is one deeper. */ + readonly depth: number; + + readonly frame: Frame; + + readonly tree: ActionTree; + + readonly metadata: Metadata; + + readonly index: number; + + readonly options: ExpandOptions; +} + +/** + * Expands one `uses` step of a composite action. A nested action that is not composite does not recurse: its step stays a provider step carrying the `uses` and `with` text as written, so a docker or node action remains the host's business and nothing about it is reinterpreted here. A `docker://` reference is carried the same way without being resolved, because there is no tree to read. + */ +async function expandNestedUses( + step: Extract, + call: NestedCall, +): Promise { + const { ofAction, through, depth, frame, tree, metadata, index, options } = call; + const fail: Fail = (construct, at) => { + throw new CompositeActionError(through, construct, at); + }; + if (step.uses.startsWith("docker://")) { + return [providerStepOf(step.uses, step.with ?? {}, call.name)]; + } + let reference: ActionReference; + try { + reference = parseActionReference(step.uses); + } catch (error) { + if (!(error instanceof Error)) throw error; + + return fail(error.message, { + file: metadata.file, + pointer: `/runs/steps/${String(index)}/uses`, + }); + } + const remote = + reference.kind === "remote" + ? { repo: repoOf(reference), at: reference.subpath ?? "" } + : { + repo: frame.repo, + at: resolveAt(frame.at, reference.path, fail), + }; + + return expandFrame( + { + ...remote, + uses: step.uses, + with: step.with ?? {}, + name: call.name, + ambient: ofAction, + chain: through, + depth: depth + 1, + nested: true, + treeOf: + reference.kind === "local" + ? async (): Promise => tree.local(reference.path) + : async (signal: AbortSignal | undefined): Promise => + options.resolve(reference, signal), + }, + options, + ); +} + +/** + * Reads the `with` mapping a provider step's `native` carries, which the importer wrote verbatim from the workflow. + */ +function withOf( + native: Readonly>, + fail: (construct: string) => never, +): Record { + const supplied: Record = {}; + if (!("with" in native)) return supplied; + const mapping = native.with; + if (!isRecord(mapping)) return fail("a with that is not a mapping"); + for (const [key, value] of Object.entries(mapping)) { + if (!isScalar(value)) return fail(`the input "${key}" with a value that is not a scalar`); + supplied[key] = value; + } + + return supplied; +} + +/** + * Expands the composite action a `provider` step names into the steps the model runs. The step's `uses` and `with` are read from its `native` mapping; every declared input is mapped from the supplied `with` or its default; the `${{ }}` expressions of the action's steps are evaluated through the evaluator of `evaluation.ts`, against a context whose `inputs` holds the mapped values and whose `github.action_path` is the directory the host resolved the action to. A nested `uses` recurses, bounded by `COMPOSITE_ACTION_MAX_DEPTH` and rejecting a cycle by naming the whole reference chain; a nested action that is not composite, and a `docker://` reference, stay provider steps carrying the text as written. + * + * Pre: `step` is a `provider` step of this provider whose `native` carries a `uses` string, and `options.context` has passed `evaluationContextSchema`. Post: the returned steps are in the order the action declared them, a step whose `if` evaluated false is left out, and each `run` step carries its own evaluated environment plus `GITHUB_ACTION_PATH`. Errors: throws `CompositeActionError` for anything that cannot be expanded, and lets `EvaluationError` through as the evaluator raised it. The step's own `when` is the caller's to honour: this function expands an action that will run, not one that may not. + */ +export async function expandCompositeAction( + step: Step, + options: ExpandOptions, +): Promise { + const fail = (construct: string): never => { + throw new CompositeActionError([], construct); + }; + if (step.kind !== "provider") return fail("a step that is not a provider step"); + if (step.provider !== PROVIDER) { + return fail(`a provider step of ${step.provider}, not of ${PROVIDER}`); + } + if (typeof step.native.uses !== "string") return fail("a provider step whose native has no uses"); + let reference: ActionReference; + try { + reference = parseActionReference(step.native.uses); + } catch (error) { + if (!(error instanceof Error)) throw error; + + return fail(error.message); + } + const where = + reference.kind === "remote" + ? { repo: repoOf(reference), at: reference.subpath ?? "" } + : { repo: "", at: resolveAt("", reference.path, fail) }; + + // The workflow's own step may carry an environment for the action, which its expressions read and its commands run with; it is merged into the caller's view here and so reaches every expanded step, while the job-level environment stays the runner's to compose. + let ambient: EvaluationContext = options.context; + if ("env" in step.native) { + const declared = step.native.env; + if (!isRecord(declared)) return fail("a step environment that is not a mapping"); + const merged: Record = { ...ambient.env }; + for (const [key, value] of Object.entries(declared)) { + if (!isScalar(value)) { + return fail(`the step environment "${key}" with a value that is not a scalar`); + } + merged[key] = evaluateTemplate(scalarText(value), ambient); + } + ambient = { ...ambient, env: merged }; + } + + return expandFrame( + { + ...where, + uses: step.native.uses, + with: withOf(step.native, fail), + name: step.name, + ambient, + chain: [], + depth: 1, + nested: false, + treeOf: async (signal: AbortSignal | undefined): Promise => + options.resolve(reference, signal), + }, + options, + ); +} diff --git a/packages/pipeline-github-actions/src/index.ts b/packages/pipeline-github-actions/src/index.ts index 7efce9a..cac8fd6 100644 --- a/packages/pipeline-github-actions/src/index.ts +++ b/packages/pipeline-github-actions/src/index.ts @@ -1,4 +1,21 @@ export { createGitHubActionsCompiler } from "./compiler.ts"; +export { + ActionReferenceError, + compositeActionSchema, + compositeStepSchema, + parseActionReference, + type ActionReference, + type CompositeAction, + type CompositeStep, +} from "./action.ts"; +export { + COMPOSITE_ACTION_MAX_DEPTH, + CompositeActionError, + expandCompositeAction, + type ActionResolver, + type ActionTree, + type ExpandOptions, +} from "./composite.ts"; export { EvaluationError, evaluateCondition, diff --git a/packages/pipeline-github-actions/test/composite.unit.test.ts b/packages/pipeline-github-actions/test/composite.unit.test.ts new file mode 100644 index 0000000..30079f0 --- /dev/null +++ b/packages/pipeline-github-actions/test/composite.unit.test.ts @@ -0,0 +1,556 @@ +import type { Step } from "@pipewright/pipeline-contract"; +import { describe, expect, it } from "vitest"; + +import { ActionReferenceError, parseActionReference } from "../src/action.ts"; +import { + COMPOSITE_ACTION_MAX_DEPTH, + CompositeActionError, + expandCompositeAction, + type ActionResolver, + type ActionTree, +} from "../src/composite.ts"; +import { evaluationContextSchema, type EvaluationContext } from "../src/evaluation.ts"; + +/** The files of one repository or workspace, keyed from its root. */ +type Files = Readonly>; + +/** Joins a location inside a tree with a path relative to it, the way a filesystem does. */ +function joined(at: string, relative: string): string { + const resolved = [...(at === "" ? [] : at.split("/"))]; + for (const segment of relative.split("/")) { + if (segment === "..") resolved.pop(); + else resolved.push(segment); + } + + return resolved.join("/"); +} + +/** An in-memory tree over one set of files, served the way a host that holds the whole repository on disk serves it. */ +function treeOf(files: Files, at: string, path: string): ActionTree { + return { + path, + read: async (relative) => Promise.resolve(files[joined(at, relative)]), + local: async (relative) => + Promise.resolve(treeOf(files, joined(at, relative), `${path}/${relative}`)), + }; +} + +/** + * A host resolver over in-memory trees: local references against one workspace, remote ones against a file set per repository at a ref. A repository being run is served from the workspace's own files, which is what a host with a checkout does. + */ +function resolverOf( + workspace: Files, + remotes: Readonly> = {}, +): ActionResolver { + return async (reference) => { + if (reference.kind === "local") { + return Promise.resolve(treeOf(workspace, reference.path, `/workspace/${reference.path}`)); + } + const key = + reference.ref === undefined + ? `${reference.owner}/${reference.repo}` + : `${reference.owner}/${reference.repo}@${reference.ref}`; + const files = remotes[key]; + if (files === undefined) return Promise.reject(new Error(`no fixture for ${key}`)); + + return Promise.resolve( + treeOf( + files, + reference.subpath ?? "", + `/actions/${key}${reference.subpath === undefined ? "" : `/${reference.subpath}`}`, + ), + ); + }; +} + +/** A context with the github fields a composite action reads. */ +const CONTEXT: EvaluationContext = evaluationContextSchema.parse({ + github: { + event_name: "push", + ref_name: "main", + base_ref: "", + sha: "abc123def456", + repository: "exadev/pipewright", + workflow: "CI", + run_id: "1536140711", + }, + env: {}, + job: { status: "success" }, + steps: {}, + needs: {}, + matrix: {}, + secrets: {}, + inputs: {}, +}); + +/** The provider step the importer builds for a workflow step with a `uses`. */ +function usesStep( + uses: string, + supplied: Readonly> = {}, +): Step { + return { + kind: "provider", + provider: "github-actions", + native: { uses, ...(Object.keys(supplied).length === 0 ? {} : { with: supplied }) }, + }; +} + +/** Expands a step against a resolver and the fixture context, and rejects anything that throws. */ +async function expanded(step: Step, resolve: ActionResolver): Promise { + const steps = await expandCompositeAction(step, { resolve, context: CONTEXT }); + + return steps; +} + +/** The error a promise rejected with, so its message and fields can be asserted. */ +async function rejectionOf(promise: Readonly>): Promise { + try { + await promise; + } catch (error) { + if (!(error instanceof Error)) throw error; + + return error; + } + + throw new Error("expected the expansion to fail"); +} + +/** The workspace: one composite action with inputs, defaults, conditions and environments. */ +const WORKSPACE: Files = { + ".github/actions/build/action.yml": ` +name: Build +description: Builds the project +branding: + icon: package + color: blue +inputs: + mode: + description: How to build + default: debug + flags: + description: Extra flags + required: true +runs: + using: composite + steps: + - name: Configure + run: echo mode=\${{ inputs.mode }} flags=\${{ inputs.flags }} + shell: bash + - name: Release only + if: inputs.mode == 'release' + run: echo releasing + shell: bash + - name: Never + if: 'false' + run: echo never + shell: sh + - name: Report + run: echo "\${{ env.REPORT }} in $(basename $GITHUB_ACTION_PATH)" + shell: bash + env: + REPORT: on-\${{ github.ref_name }} + working-directory: \${{ github.action_path }} +`, +}; + +describe("parseActionReference", () => { + it("reads each form of a uses value", () => { + expect(parseActionReference("./.github/actions/build")).toEqual({ + kind: "local", + path: ".github/actions/build", + }); + expect(parseActionReference("exadev/pipewright")).toEqual({ + kind: "remote", + owner: "exadev", + repo: "pipewright", + subpath: undefined, + ref: undefined, + }); + expect(parseActionReference("exadev/pipewright@v2")).toEqual({ + kind: "remote", + owner: "exadev", + repo: "pipewright", + subpath: undefined, + ref: "v2", + }); + expect(parseActionReference("exadev/pipewright/.github/actions/setup@main")).toEqual({ + kind: "remote", + owner: "exadev", + repo: "pipewright", + subpath: ".github/actions/setup", + ref: "main", + }); + }); + + it("keeps a local path's climb and normalises its stops", () => { + expect(parseActionReference("./inner/./../sibling")).toEqual({ + kind: "local", + path: "inner/../sibling", + }); + }); + + it("rejects the values that name no repository tree", () => { + const rejected = (uses: string): string => { + try { + parseActionReference(uses); + } catch (error) { + if (!(error instanceof ActionReferenceError)) throw error; + + return error.construct; + } + + throw new Error(`expected ${JSON.stringify(uses)} to fail`); + }; + expect(rejected("docker://alpine:3.20")).toBe( + "a container image, which needs a container runtime; container actions are out of scope on the host", + ); + expect(rejected("$/path/to/action")).toContain("the same-repository alias"); + expect(rejected("checkout@v4")).toContain("that is not owner/repo"); + expect(rejected("exadev/pipewright/sub/../..@main")).toContain( + "climbs above the repository it names", + ); + expect(rejected("exadev/pipewright@")).toContain("no ref after @"); + }); +}); + +describe("expandCompositeAction", () => { + it("expands a local composite action with defaults, conditions and environments", async () => { + const steps = await expanded( + usesStep("./.github/actions/build", { flags: "--fast" }), + resolverOf(WORKSPACE), + ); + const where = "/workspace/.github/actions/build"; + expect(steps).toEqual([ + { + kind: "run", + name: "Configure", + run: "echo mode=debug flags=--fast", + env: { GITHUB_ACTION_PATH: where }, + }, + { + kind: "run", + name: "Report", + run: 'echo "on-main in $(basename $GITHUB_ACTION_PATH)"', + env: { REPORT: "on-main", GITHUB_ACTION_PATH: where }, + workingDirectory: where, + }, + ]); + }); + + it("keeps a step whose condition holds and drops one whose does not", async () => { + const steps = await expanded( + usesStep("./.github/actions/build", { mode: "release", flags: "-O2" }), + resolverOf(WORKSPACE), + ); + expect(steps.map((step) => (step.kind === "run" ? step.name : undefined))).toEqual([ + "Configure", + "Release only", + "Report", + ]); + expect(steps[1]).toEqual({ + kind: "run", + name: "Release only", + run: "echo releasing", + env: { GITHUB_ACTION_PATH: "/workspace/.github/actions/build" }, + }); + }); + + it("expands a same-repository reference served from the checkout", async () => { + const resolve = resolverOf(WORKSPACE, { "exadev/pipewright@main": WORKSPACE }); + const steps = await expanded( + usesStep("exadev/pipewright/.github/actions/build@main", { flags: "--ci" }), + resolve, + ); + expect(steps).toHaveLength(2); + expect(steps[0]).toEqual({ + kind: "run", + name: "Configure", + run: "echo mode=debug flags=--ci", + env: { GITHUB_ACTION_PATH: "/actions/exadev/pipewright@main/.github/actions/build" }, + }); + }); + + it("expands a remote action's nesting, and leaves actions that are not composite as provider steps", async () => { + const OUTER: Files = { + "action.yml": ` +name: Outer +description: Wraps other actions +inputs: + name: + description: Who to greet + default: world +runs: + using: composite + steps: + - name: Start + run: echo start + shell: bash + - name: Greet + uses: ./inner + with: + who: \${{ inputs.name }} + - name: Cache + uses: actions/cache@v4 + with: + key: build-\${{ github.sha }} + - name: Scan + uses: docker://aquasec/trivy:latest + - name: Finish + run: echo done + shell: bash +`, + "inner/action.yml": ` +name: Inner +description: Greets someone +inputs: + who: + description: Who + default: someone + suffix: + description: How to end the greeting + default: '!' +runs: + using: composite + steps: + - run: echo hello \${{ inputs.who }}\${{ inputs.suffix }} + shell: bash +`, + }; + const remotes: Readonly> = { + "exadev/outer@v1": OUTER, + "actions/cache@v4": { + "action.yml": + "name: Cache\ndescription: Caches things\nruns:\n using: node20\n main: dist/index.js\n", + }, + }; + const steps = await expanded( + usesStep("exadev/outer@v1", { name: "pipewright" }), + resolverOf(WORKSPACE, remotes), + ); + expect(steps).toEqual([ + { + kind: "run", + name: "Start", + run: "echo start", + env: { GITHUB_ACTION_PATH: "/actions/exadev/outer@v1" }, + }, + { + kind: "run", + run: "echo hello pipewright!", + env: { GITHUB_ACTION_PATH: "/actions/exadev/outer@v1/inner" }, + }, + { + kind: "provider", + provider: "github-actions", + name: "Cache", + native: { uses: "actions/cache@v4", with: { key: "build-${{ github.sha }}" } }, + }, + { + kind: "provider", + provider: "github-actions", + name: "Scan", + native: { uses: "docker://aquasec/trivy:latest" }, + }, + { + kind: "run", + name: "Finish", + run: "echo done", + env: { GITHUB_ACTION_PATH: "/actions/exadev/outer@v1" }, + }, + ]); + }); + + it("rejects a cycle naming the whole reference chain", async () => { + const files: Files = { + "actions/outer/action.yml": ` +name: Outer +description: Calls its neighbour +runs: + using: composite + steps: + - uses: ./inner +`, + "actions/outer/inner/action.yml": ` +name: Inner +description: Calls back +runs: + using: composite + steps: + - uses: ./.. +`, + }; + const error = await rejectionOf(expanded(usesStep("./actions/outer"), resolverOf(files))); + if (!(error instanceof CompositeActionError)) { + throw new Error(`expected CompositeActionError, got ${String(error)}`); + } + expect(error.chain).toEqual(["./actions/outer", "./actions/outer/inner", "./actions/outer"]); + expect(error.message).toContain("already being expanded"); + expect(error.message).toContain("./actions/outer -> ./actions/outer/inner -> ./actions/outer"); + }); + + it("expands the bound's depth of nesting and rejects one deeper", async () => { + const chainOf = (count: number): Record => { + const files: Record = {}; + for (let level = 1; level <= count; level += 1) { + const deepest = level === count; + // Each level's action lives inside the one before it, because its step's ./ reference is relative to that action's directory. + const directory = Array.from( + { length: level }, + (_unused, index) => `a${String(index + 1)}`, + ).join("/"); + files[`${directory}/action.yml`] = ` +name: Level +description: One level of a generated chain +runs: + using: composite + steps: +${deepest ? " - run: echo deepest\n shell: bash" : ` - uses: ./a${String(level + 1)}`} +`; + } + + return files; + }; + const ten = chainOf(COMPOSITE_ACTION_MAX_DEPTH); + const within = await expanded(usesStep("./a1"), resolverOf(ten)); + expect(within).toEqual([ + { + kind: "run", + run: "echo deepest", + env: { + GITHUB_ACTION_PATH: `/workspace/a1/${Array.from( + { length: COMPOSITE_ACTION_MAX_DEPTH - 1 }, + (_unused, index) => `a${String(index + 2)}`, + ).join("/")}`, + }, + }, + ]); + + const error = await rejectionOf( + expanded(usesStep("./a1"), resolverOf(chainOf(COMPOSITE_ACTION_MAX_DEPTH + 1))), + ); + expect(error.message).toContain( + `nesting ${String(COMPOSITE_ACTION_MAX_DEPTH + 1)} composite actions deep`, + ); + }); + + it("rejects malformed metadata with the file and the pointer", async () => { + const files: Files = { + "bad/action.yml": ` +name: Bad +description: A step with no shell +runs: + using: composite + steps: + - run: echo hi +`, + }; + const error = await rejectionOf(expanded(usesStep("./bad"), resolverOf(files))); + if (!(error instanceof CompositeActionError)) { + throw new Error(`expected CompositeActionError, got ${String(error)}`); + } + expect(error.file).toBe("./bad/action.yml"); + expect(error.pointer).toBe("/runs/steps/0/shell"); + expect(error.message).toContain("./bad/action.yml /runs/steps/0/shell"); + }); + + it("rejects a step with both run and uses, and a key no composite step has", async () => { + const files: Files = { + "both/action.yml": ` +name: Both +description: A step that has both +runs: + using: composite + steps: + - run: echo hi + uses: ./other + shell: bash +`, + "unknown-key/action.yml": ` +name: Unknown +description: A step key outside the documented set +runs: + using: composite + steps: + - run: echo hi + shell: bash + timeout-minutes: 10 +`, + }; + const both = await rejectionOf(expanded(usesStep("./both"), resolverOf(files))); + expect(both.message).toContain("a step that has both run and uses"); + expect(both.message).toContain("/runs/steps/0"); + + const unknown = await rejectionOf(expanded(usesStep("./unknown-key"), resolverOf(files))); + expect(unknown.message).toContain('Unrecognized key: "timeout-minutes"'); + expect(unknown.message).toContain("/runs/steps/0"); + }); + + it("rejects an action that is not composite, an undeclared input and an unset required one", async () => { + const nodeAction: Files = { + "action.yml": + "name: Setup\ndescription: Sets up\nruns:\n using: node20\n main: dist/index.js\n", + }; + const top = await rejectionOf( + expanded( + usesStep("actions/setup@v4"), + resolverOf(WORKSPACE, { "actions/setup@v4": nodeAction }), + ), + ); + expect(top.message).toContain('runs.using "node20"'); + + const undeclared = await rejectionOf( + expanded( + usesStep("./.github/actions/build", { flags: "-v", extra: "1" }), + resolverOf(WORKSPACE), + ), + ); + expect(undeclared.message).toContain('the input "extra", which the action does not declare'); + + const required = await rejectionOf( + expanded(usesStep("./.github/actions/build"), resolverOf(WORKSPACE)), + ); + expect(required.message).toContain( + 'the required input "flags", which no with supplied and which declares no default', + ); + }); + + it("rejects a shell and a continue-on-error the model has no form for", async () => { + const files: Files = { + "pwsh/action.yml": ` +name: Powershell +description: A step in another interpreter +runs: + using: composite + steps: + - run: Write-Output hi + shell: pwsh +`, + "tolerant/action.yml": ` +name: Tolerant +description: A step that may not fail the job +runs: + using: composite + steps: + - run: rm -rf out + shell: bash + continue-on-error: true +`, + }; + const pwsh = await rejectionOf(expanded(usesStep("./pwsh"), resolverOf(files))); + expect(pwsh.message).toContain('the shell "pwsh"'); + expect(pwsh.message).toContain("/runs/steps/0/shell"); + + const tolerant = await rejectionOf(expanded(usesStep("./tolerant"), resolverOf(files))); + expect(tolerant.message).toContain("continue-on-error"); + }); + + it("rejects a step that is not a provider step of this provider", async () => { + const error = await rejectionOf( + expandCompositeAction( + { kind: "run", run: "echo hi" }, + { resolve: resolverOf(WORKSPACE), context: CONTEXT }, + ), + ); + expect(error.message).toContain("a step that is not a provider step"); + }); +});