Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A workflow file mixes two things. The orchestration (triggers, job graph, matrix

**Emit native files.** `emit` compiles a pipeline to the provider's own syntax. `check` fails when a committed file no longer matches what the tool would write. Use this where the provider must read orchestration from the repository.

**Run directly.** `run` executes the pipeline through an in-process runner that asks an injected executor to run each command. Nothing is generated. A single job, a step range or one matrix combination can be run alone.
**Run directly.** `run` executes the pipeline through an in-process runner that asks an injected executor to run each command. Nothing is generated. A single job, a step range or one matrix combination can be run alone. Around each step the runner applies the provider's file commands, as a hosted runner does: the step writes to `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH` and `GITHUB_STEP_SUMMARY`, and its stdout is read for `::add-mask::` and the log-level commands, so outputs reach the step's record, environment and paths reach the job's later steps, and masked values are not echoed.

**Query the platform.** A separate read-only contract lists runs, checks, logs and the checks a branch requires. It does not depend on the pipeline model.

Expand Down
2 changes: 1 addition & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ A job that names no runner label has an empty `runsOn`, which means the provider

## Wrapper form

Every adapter can render a job in the wrapper form: orchestration stays native and each contiguous run of portable steps becomes one call to this tool's runner for exactly those step indexes. A run step with `form: "native"` stays in the provider's syntax, which is how an install step runs before the tool exists. A matrix combination is passed as a quoted `--matrix "axis=value"` argument: as a shell-quoted literal where the adapter expands the matrix itself, and through the provider's own substitution (`${{ matrix.node }}`, `$node`, `<< parameters.node >>`) where the provider does. The run context the runner reads comes from the provider's environment variables; a value that provider does not expose (Bitbucket cannot tell a push from a manual run, CircleCI exposes no workflow name) is absent, and the runner fails only if a step or condition reads it.
Every adapter can render a job in the wrapper form: orchestration stays native and each contiguous run of portable steps becomes one call to this tool's runner for exactly those step indexes. A run step with `form: "native"` stays in the provider's syntax, which is how an install step runs before the tool exists. A matrix combination is passed as a quoted `--matrix "axis=value"` argument: as a shell-quoted literal where the adapter expands the matrix itself, and through the provider's own substitution (`${{ matrix.node }}`, `$node`, `<< parameters.node >>`) where the provider does. The run context the runner reads comes from the provider's environment variables; a value that provider does not expose (Bitbucket cannot tell a push from a manual run, CircleCI exposes no workflow name) is absent, and the runner fails only if a step or condition reads it. The runner's own file commands (`GITHUB_OUTPUT` and its siblings) apply between the steps of one wrapper call and are not forwarded to the provider's runner, so a native step after the call does not see what an inner step wrote.

## Check names

Expand Down
79 changes: 74 additions & 5 deletions packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter as pathDelimiter, dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

import type { StepScratch } from "@pipewright/pipeline-contract";

import { main, type Io } from "./main.ts";

const cwd = process.cwd();
Expand All @@ -17,6 +20,53 @@ async function readOptional(path: string): Promise<string | undefined> {
}
}

/**
* The run's scratch area: one directory per run under the operating system's temporary directory, one empty file per command per step, removed once the runner has read it and in whole when the run ends, so no step can read another's files and nothing the run wrote outlives it. Reading a file that is gone is an error, not an empty read, so a step that deletes its own command file is heard.
*/
async function stepScratch(): Promise<StepScratch> {
const root = await mkdtemp(join(tmpdir(), "pipewright-"));
let stepCount = 0;

return {
async create() {
const directory = join(root, String(stepCount));
stepCount += 1;
await mkdir(directory, { recursive: true });
const files = {
output: join(directory, "output"),
env: join(directory, "env"),
path: join(directory, "path"),
summary: join(directory, "summary"),
};
await Promise.all([
writeFile(files.output, ""),
writeFile(files.env, ""),
writeFile(files.path, ""),
writeFile(files.summary, ""),
]);

return files;
},
async read(files) {
const [output, env, path, summary] = await Promise.all([
readFile(files.output, "utf8"),
readFile(files.env, "utf8"),
readFile(files.path, "utf8"),
readFile(files.summary, "utf8"),
Comment thread
Mearman marked this conversation as resolved.
]);

return { output, env, path, summary };
},
async release(files) {
// A later step's process must not read what an earlier one wrote, so the directory and its files go once the runner has read them.
await rm(dirname(files.output), { recursive: true, force: true });
},
async dispose() {
await rm(root, { recursive: true, force: true });
},
};
}

const io: Io = {
cwd,
env: process.env,
Expand All @@ -33,22 +83,41 @@ const io: Io = {
await mkdir(dirname(target), { recursive: true });
await writeFile(target, content);
},
scratch: await stepScratch(),
executor: {
async execute(execution, signal) {
return new Promise((done, fail) => {
// A run step stops at its first failing command, as it does under GitHub's `bash -e`, so a multi-line step cannot pass with an early command failed.
const child = spawn("sh", ["-e", "-c", execution.command], {
stdio: "inherit",
// Both streams are piped, not inherited, because either can carry a value a step masked, so the runner emits both through its masks; a step's output appears when the step ends.
stdio: ["inherit", "pipe", "pipe"],
cwd:
execution.workingDirectory === undefined
? cwd
: resolve(cwd, execution.workingDirectory),
env: { ...process.env, ...execution.env },
env: {
...process.env,
...execution.env,
// The job's accumulated path entries are searched before the host's own PATH.
PATH: [...execution.pathEntries, process.env.PATH]
.filter((entry): entry is string => entry !== undefined)
.join(pathDelimiter),
},
...(signal === undefined ? {} : { signal }),
});
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", fail);
child.on("close", (code) => {
done({ exitCode: code ?? 1 });
done({ exitCode: code ?? 1, stdout, stderr });
});
});
},
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ import {
type CommandExecutor,
type ContextKey,
type FileReader,
type LogEntry,
type MatrixCombination,
type Pipeline,
type PipelineCompiler,
type PipelineImporter,
type RunOutput,
type StepScratch,
} from "@pipewright/pipeline-contract";
import {
createBitbucketPipelinesCompiler,
Expand Down Expand Up @@ -54,6 +57,8 @@ export interface Io {
readonly readOptional: (path: string) => Promise<string | undefined>;
readonly writeFile: (path: string, content: string) => Promise<void>;
readonly executor: CommandExecutor;
/** Allocates and reads each step's command files for `run`; the composition root owns the filesystem. */
readonly scratch: StepScratch;
/** Loads a TypeScript pipeline module and returns its exports. Injected because importing code is a runtime effect the CLI's tests must not perform. */
readonly importModule: (path: string) => Promise<unknown>;
readonly fetch: typeof fetch;
Expand Down Expand Up @@ -219,6 +224,36 @@ function parseRange(text: string): { from: number; to: number } {
return { from: Number(match[1]), to: Number(match[2]) };
}

/**
* The run's output as terminal text: a step's own lines as they came, a routed level with the place it pointed at, and each summary's markdown.
*/
function cliRunOutput(io: Io): RunOutput {
const placed = (entry: LogEntry): string => {
const annotation = entry.annotation;
if (annotation === undefined) return `${entry.level}: ${entry.message}`;
const lines =
annotation.line === undefined
? ""
: `:${String(annotation.line)}${
annotation.endLine === undefined || annotation.endLine === annotation.line
? ""
: `-${String(annotation.endLine)}`
}`;
const title = annotation.title === undefined ? "" : ` (${annotation.title})`;

return `${entry.level} ${annotation.file ?? ""}${lines}${title}: ${entry.message}`;
};

return {
log(entry) {
io.stdout(entry.level === "info" ? `${entry.message}\n` : `${placed(entry)}\n`);
},
summary(step) {
io.stdout(step.text.endsWith("\n") ? step.text : `${step.text}\n`);
},
};
}

function repository(text: string | undefined): { owner: string; repo: string } {
const [owner, repo, extra] = required(text, "--repo").split("/");
if (
Expand Down Expand Up @@ -338,6 +373,8 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
const matrix: MatrixCombination = Object.fromEntries(keyValues(values.matrix, "--matrix"));
const report = await createLocalRunner().run(pipeline, {
executor: io.executor,
scratch: io.scratch,
output: cliRunOutput(io),
context: resolveContext(io.env, {
...(values.local === true ? localContext(pipeline.name) : {}),
...contextOverrides(values.context),
Expand Down
133 changes: 130 additions & 3 deletions packages/cli/test/main.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";

import type { CommandExecution, StepScratch } from "@pipewright/pipeline-contract";

import { main, resolveContext, type Io } from "../src/index.ts";

const SCHEMA = "https://unpkg.com/@pipewright/pipeline-contract/dist/pipeline.schema.json";
Expand Down Expand Up @@ -30,13 +32,72 @@ interface Harness {
readonly err: string[];
readonly files: Map<string, string>;
readonly commands: string[];
readonly executions: CommandExecution[];
}

/** What each executed command leaves behind: the stdout it produced and the contents it wrote to the command files its environment names. */
interface HarnessScript {
readonly stdout: string;
readonly writes: Partial<Record<"output" | "env" | "path" | "summary", string>>;
}

interface HarnessOptions {
readonly files?: Record<string, unknown>;
readonly modules?: Record<string, unknown>;
readonly env?: Readonly<Record<string, string | undefined>>;
readonly failing?: (command: string) => boolean;
readonly script?: (execution: CommandExecution) => HarnessScript;
}

/** The scratch over the harness's own file store, so a scripted executor can write the command files by the paths the runner passed. */
function storeScratch(store: Map<string, string>): StepScratch {
let stepCount = 0;

return {
async create() {
const files = {
output: `.scratch/${String(stepCount)}/output`,
env: `.scratch/${String(stepCount)}/env`,
path: `.scratch/${String(stepCount)}/path`,
summary: `.scratch/${String(stepCount)}/summary`,
};
stepCount += 1;
for (const path of [files.output, files.env, files.path, files.summary]) {
store.set(path, "");
}

return Promise.resolve(files);
},
async release(files) {
for (const path of [files.output, files.env, files.path, files.summary]) {
store.delete(path);
}

return Promise.resolve();
},
async dispose() {
for (const path of [...store.keys()]) {
if (path.startsWith(".scratch/")) store.delete(path);
}

return Promise.resolve();
},
async read(files) {
const content = (path: string): string => {
const value = store.get(path);
if (value === undefined) throw new Error(`no such file ${path}`);

return value;
};

return Promise.resolve({
output: content(files.output),
env: content(files.env),
path: content(files.path),
summary: content(files.summary),
});
},
};
}

function harness(options: HarnessOptions = {}): Harness {
Expand All @@ -45,6 +106,7 @@ function harness(options: HarnessOptions = {}): Harness {
env = {},
failing = () => false,
modules = {},
script = (): HarnessScript => ({ stdout: "", writes: {} }),
} = options;
const store = new Map(
Object.entries(files).map(([path, value]) => [
Expand All @@ -55,6 +117,7 @@ function harness(options: HarnessOptions = {}): Harness {
const out: string[] = [];
const err: string[] = [];
const commands: string[] = [];
const executions: CommandExecution[] = [];
const io: Io = {
cwd: "/repo",
env,
Expand All @@ -79,11 +142,31 @@ function harness(options: HarnessOptions = {}): Harness {

return Promise.resolve();
},
scratch: storeScratch(store),
executor: {
async execute(execution) {
commands.push(execution.command);

return Promise.resolve({ exitCode: failing(execution.command) ? 1 : 0 });
executions.push(execution);
const answer = script(execution);
const variables = {
output: "GITHUB_OUTPUT",
env: "GITHUB_ENV",
path: "GITHUB_PATH",
summary: "GITHUB_STEP_SUMMARY",
} as const;
for (const file of ["output", "env", "path", "summary"] as const) {
const content = answer.writes[file];
if (content === undefined) continue;
const path = execution.env[variables[file]];
if (path === undefined) throw new Error(`the runner did not pass ${variables[file]}`);
store.set(path, content);
}

return Promise.resolve({
exitCode: failing(execution.command) ? 1 : 0,
stdout: answer.stdout,
stderr: "",
});
},
},
async importModule(path) {
Expand All @@ -95,7 +178,7 @@ function harness(options: HarnessOptions = {}): Harness {
fetch: async () => Promise.reject(new Error("network is not available in this test")),
};

return { io, out, err, files: store, commands };
return { io, out, err, files: store, commands, executions };
}

const EMIT = ["--pipeline", "ci/ci.pipeline.json", "--provider", "github-actions"];
Expand Down Expand Up @@ -290,6 +373,50 @@ describe("run", () => {
expect(h.err.join("")).toContain("refName");
expect(h.commands).toEqual([]);
});

it("writes a step's environment file entries into later steps' environments", async () => {
const oneJob = {
...pipeline,
jobs: {
build: {
name: "Build",
runsOn: ["ubuntu-latest"],
steps: [
{ kind: "run", run: "pnpm run lint" },
{ kind: "run", run: "echo gate" },
],
},
},
};
const h = harness({
files: { "ci/ci.pipeline.json": oneJob },
script: (execution) =>
execution.command === "pnpm run lint"
? { stdout: "", writes: { env: "FROM_STEP=set\n" } }
: { stdout: "", writes: {} },
});
expect(await main(["run", "--pipeline", "ci/ci.pipeline.json", ...context], h.io)).toBe(0);
const gate = h.executions.find((execution) => execution.command === "echo gate");
expect(gate?.env.FROM_STEP).toBe("set");
});

it("echoes a step's stdout with masked values hidden and routed levels placed", async () => {
const h = harness({
script: (execution) =>
execution.command === "pnpm run lint"
? {
stdout:
"::add-mask::s3cret\nthe token is s3cret\n::warning file=app.ts,line=2,endLine=4,title=Check::mind the gap\n",
writes: { summary: "## Lint\n" },
}
: { stdout: "", writes: {} },
});
expect(await main(["run", "--pipeline", "ci/ci.pipeline.json", ...context], h.io)).toBe(0);
const printed = h.out.join("");
expect(printed).toContain("the token is ***");
expect(printed).toContain("warning app.ts:2-4 (Check): mind the gap\n");
expect(printed).toContain("## Lint\n");
});
});

describe("resolveContext", () => {
Expand Down
Loading
Loading