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
12 changes: 12 additions & 0 deletions biome.local.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@
}
}
},
// Every line the action or the CLI prints goes through its sink (src/action/io.ts) or the CLI's streams, where
// text the Actions runner would read as a workflow command is rewritten; a console call elsewhere bypasses both.
{
"includes": ["src/**", "!src/action/io.ts"],
"linter": {
"rules": {
"suspicious": {
"noConsole": "error"
}
}
}
},
{
"includes": ["src/**"],
"plugins": ["./lint/never-throw.grit"]
Expand Down
15 changes: 9 additions & 6 deletions docs/start/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,12 @@ Two inputs have no command-line form: `--private-report artifact` is refused (th

Four flags are the command line's own, and `init` has one more, `--force` (replace an existing settings file):

| Flag | Does |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--token <value>` | The token; `GITHUB_TOKEN` when absent |
| `--json` | Print the outputs as one JSON object on stdout; log lines move to stderr |
| `--summary <file>` | Append the run's markdown summary (the action's step summary) to this file; under GitHub Actions the step summary itself when absent; `init`, `validate`, and `permissions` have none |
| `--verbose` | Show the debug trace on stderr |
| Flag | Does |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--token <value>` | The token; `GITHUB_TOKEN` when absent |
| `--json` | Print the outputs as one JSON object on stdout; log lines move to stderr |
| `--summary <file>` | Append the run's markdown summary (the action's step summary) to this file; under GitHub Actions the step summary itself when absent. The file is opened, and its directories created, before the run starts: a path that cannot be opened is refused before any request, and the empty file and its directories stay when the run is then refused for another reason. `init`, `validate`, and `permissions` never open it. |
| `--verbose` | Show the debug trace on stderr |

## Output and exit codes

Expand All @@ -158,12 +158,15 @@ skipped-sections=
repos-result={}
```

A log line is one line in every mode: a line break inside a logged value is written `%0D` or `%0A`.

As a GitHub Actions step (`GITHUB_ACTIONS=true` in the environment, as the runner sets it) the command line reports as the action does, in addition:

- **A masked value** is a `::add-mask::` command.
- **An annotation** is a `::notice::`, `::warning::`, or `::error::` command on stdout instead of the stderr line.
- **The outputs** land in the step outputs (`GITHUB_OUTPUT`).
- **The summary** lands in the step summary (`GITHUB_STEP_SUMMARY`) unless `--summary` names a file.
- **Every other line** has `##[` written `%23%23[`, the `--json` object's text included, and a `::` that opens a line written `%3A%3A`, since the runner reads both as workflow commands.

Those commands share stdout with the `--json` object, so a step reads the outputs from the step outputs, not by parsing stdout; the one-object rule below holds in a terminal.

Expand Down
34 changes: 31 additions & 3 deletions src/action/io.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { appendFileSync } from "node:fs";
import * as core from "@actions/core";
import { type Io, maskRegistry, type OutputName } from "../index.js";
import { type Io, maskRegistry, type OutputName, redactRanges } from "../index.js";

/** Generated into the action.yml `outputs` block (bun run build:action-docs); the satisfies clause locks the keys to OutputName. */
export const OUTPUT_DECLS = {
Expand All @@ -21,6 +21,17 @@
},
} as const satisfies Record<OutputName, { readonly description: string }>;

/**

Check warning on line 24 in src/action/io.ts

View workflow job for this annotation

GitHub Actions / platform / standard-checks

src/action/io.ts:24: one-line-comment
* `::` at the start of a line, after the white space the runner's TrimStart() removes: the 25 characters .NET's
* Char.IsWhiteSpace accepts (U+0009..U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000..U+200A, U+2028, U+2029,
* U+202F, U+205F, U+3000).
*/
const LEADING_COMMAND =
/^([\t-\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]*)::/;

/** The masks this action registered with the runner; the log sink redacts them itself before it escapes. */
const registry = maskRegistry(core.setSecret);

// The static map keeps the namespace access tree-shakeable (biome noDynamicNamespaceImportAccess) and captures the references at module load, so a spy installed after import is not seen; @actions/core owns the workflow-command escaping (%, CR, LF).
const annotators = { notice: core.notice, warning: core.warning, error: core.error } as const;

Expand All @@ -43,11 +54,28 @@
appendFileSync(file, `${markdown}\n`);
}

/**
* `line` as one log line the runner cannot read as a workflow command, so text from a settings file or from live
* GitHub state, printed by this action's own writers, cannot issue one. Not covered: what a dependency prints
* itself, and a writer that bypasses this sink on purpose. test/cli/actions.test.ts holds the CLI to the same output.
*
* the runner reads `::` at the start of a line, after leading whitespace -> CR and LF are written %0D and %0A, a leading `::` %3A%3A
* the runner reads `##[` anywhere in a line -> it is written %23%23[
*/
function plainLine(line: string): string {
return line
.replace(/\r/g, "%0D")
.replace(/\n/g, "%0A")
.replace(LEADING_COMMAND, "$1%3A%3A")
.replace(/##\[/g, "%23%23[");
}

export const actionsIo: Io = {
annotate,
log: (line) => console.log(line),
// Redacted here, before the escapes: the runner masks a registered value only where it stands as registered.
log: (line) => console.log(plainLine(redactRanges(line, registry.masked()))),
debug: (line) => core.debug(line),
summary: appendSummary,
output: setOutput,
...maskRegistry(core.setSecret),
...registry,
};
32 changes: 30 additions & 2 deletions src/cli/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
import { EOL } from "node:os";
import type { AnnotationLevel, OutputName } from "../index.js";

/**

Check warning on line 12 in src/cli/actions.ts

View workflow job for this annotation

GitHub Actions / platform / standard-checks

src/cli/actions.ts:12: one-line-comment
* `::` where the runner reads it: at the start of a line, after the white space its TrimStart() removes. A line
* starts at the start of a chunk or after CR or LF, the only characters the runner's reader ends one at. The white
* space is the 25 characters .NET's Char.IsWhiteSpace accepts: U+0009..U+000D, U+0020, U+0085, U+00A0, U+1680,
* U+2000..U+200A, U+2028, U+2029, U+202F, U+205F, U+3000.
*/
const LINE_OPENING_COMMAND =
/(^|[\r\n])([\t-\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]*)::/g;

export interface ActionsRunner {
/** GITHUB_OUTPUT; the runner always sets it, so an unset one only drops the record, as the action's Io does. */
readonly outputFile: string | undefined;
Expand All @@ -35,10 +44,29 @@
};
}

/**
* `text` with each line break in the runner's spelling (%0D, %0A). The runner reads `::` as a workflow command at
* the start of a line, after leading whitespace, so text after a raw break could open one.
*/
export function oneLine(text: string): string {
return text.replace(/\r/g, "%0D").replace(/\n/g, "%0A");
}

/**
* `text` with no line the runner reads as a workflow command, so text from a settings file or from live GitHub
* state, printed by any of the CLI's own writers, cannot issue one. Not covered: what a dependency prints itself,
* a marker split across two writes, and a writer that bypasses the stream on purpose.
*
* the runner reads `::` at the start of a line, after leading whitespace -> that `::` is written %3A%3A
* the runner reads `##[` anywhere in a line -> it is written %23%23[
*/
export function withoutCommands(text: string): string {
return text.replace(LINE_OPENING_COMMAND, "$1$2%3A%3A").replace(/##\[/g, "%23%23[");
}

/** One `::name::message` line with the runner's data escaping (%, CR, LF), as @actions/core issues it. */
export function workflowCommand(name: "add-mask" | AnnotationLevel, message: string): string {
const data = message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
return `::${name}::${data}${EOL}`;
return `::${name}::${oneLine(message.replace(/%/g, "%25"))}${EOL}`;
}

/** One GITHUB_OUTPUT record in the heredoc form the runner reads, with the fresh delimiter @actions/core mints per record. */
Expand Down
53 changes: 40 additions & 13 deletions src/cli/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* beside that redaction, so a gsac step and the action step read alike.
*/

import { appendFileSync } from "node:fs";
import { appendFileSync, mkdirSync, openSync } from "node:fs";
import { dirname } from "node:path";
import { Writable } from "node:stream";
import {
type ConsolaReporter,
Expand All @@ -14,6 +15,7 @@ import {
LogLevels,
type LogType,
} from "consola";
import { err, ok, type Result } from "neverthrow";
import pc from "picocolors";
import {
type AnnotationLevel,
Expand All @@ -23,7 +25,13 @@ import {
type OutputName,
redactRanges,
} from "../index.js";
import { type ActionsRunner, outputRecord, workflowCommand } from "./actions.js";
import {
type ActionsRunner,
oneLine,
outputRecord,
withoutCommands,
workflowCommand,
} from "./actions.js";

export interface CliStreams {
readonly stdout: Writable;
Expand All @@ -50,17 +58,17 @@ class Verbatim {
constructor(readonly text: string) {}
}

/** A stream that redacts each chunk before handing it to `target`; one queue, so no chunk overtakes another. */
/** A stream that rewrites each chunk that is not Verbatim before handing it to `target`; one queue, so no chunk overtakes another. */
class RedactingStream extends Writable {
constructor(
private readonly target: Writable,
private readonly redact: (text: string) => string,
private readonly rewrite: (text: string) => string,
) {
super({ objectMode: true });
}

override _write(chunk: unknown, _encoding: BufferEncoding, callback: () => void): void {
const text = chunk instanceof Verbatim ? chunk.text : this.redact(String(chunk));
const text = chunk instanceof Verbatim ? chunk.text : this.rewrite(String(chunk));
// A full target holds the next chunk until it drains, so backpressure reaches the writer.
if (this.target.write(text)) {
callback();
Expand All @@ -83,14 +91,17 @@ export function maskedStreams(streams: CliStreams, runner?: ActionsRunner): Mask
runner === undefined ? () => {} : (value) => command(workflowCommand("add-mask", value)),
);
const redact = (text: string): string => redactRanges(text, registry.masked());
const stdout = new RedactingStream(streams.stdout, redact);
// Under a runner a chunk that is no finished command also loses what the runner reads as one, so no writer has to remember it.
const rewrite =
runner === undefined ? redact : (text: string): string => withoutCommands(redact(text));
const stdout = new RedactingStream(streams.stdout, rewrite);
/** A finished command line, queued behind the redacted writes before it. */
function command(line: string): void {
stdout.write(new Verbatim(line));
}
return {
stdout,
stderr: new RedactingStream(streams.stderr, redact),
stderr: new RedactingStream(streams.stderr, rewrite),
redact,
runner:
runner === undefined
Expand All @@ -103,6 +114,21 @@ export function maskedStreams(streams: CliStreams, runner?: ActionsRunner): Mask
};
}

/**
* The descriptor of `path` opened for append, its directories created first as the rendered and snapshot files
* create theirs. Taken before the run sends a request, so a path the open refuses is the refusal here, naming `--summary`.
*/
export function openSummaryFile(path: string): Result<number, string> {
try {
mkdirSync(dirname(path), { recursive: true });
return ok(openSync(path, "a"));
} catch (error) {
return err(
`cannot write the summary to ${path}: ${String(error)}. Check that --summary names a writable path`,
);
}
}

export interface CliIoOptions {
/** The masked boundary every write goes through. */
readonly streams: MaskedStreams;
Expand All @@ -113,8 +139,8 @@ export interface CliIoOptions {
/** Show the debug trace. */
readonly verbose: boolean;

/** The file summary blocks are appended to; none falls back to the runner's step summary, or drops them. */
readonly summaryFile?: string;
/** The descriptor openSummaryFile() took on `--summary`'s file, which summary blocks are appended through; none falls back to the runner's step summary, or drops them. */
readonly summaryFd?: number;

/** Color the level labels. */
readonly colors: boolean;
Expand Down Expand Up @@ -172,7 +198,7 @@ export function cliIo(options: CliIoOptions): CliIo {
const consola = createConsola({ level, reporters: [reporter], throttle: 0 });
const logStream = options.json ? streams.stderr : streams.stdout;
const { runner } = streams;
const summaryFile = options.summaryFile ?? runner?.summaryFile;
const summaryTarget = options.summaryFd ?? runner?.summaryFile;
const outputs = new Map<OutputName, string>();
const annotate: Io["annotate"] =
runner === undefined
Expand All @@ -181,11 +207,12 @@ export function cliIo(options: CliIoOptions): CliIo {
return {
io: {
annotate,
log: (line) => logStream.write(`${line}\n`),
// Redacted before the line breaks are escaped: a masked value that spans lines no longer equals itself once escaped.
log: (line) => logStream.write(`${oneLine(streams.redact(line))}\n`),
debug: (line) => consola.debug(line),
summary: (markdown) => {
if (summaryFile !== undefined) {
appendFileSync(summaryFile, `${streams.redact(markdown)}\n`);
if (summaryTarget !== undefined) {
appendFileSync(summaryTarget, `${streams.redact(markdown)}\n`);
}
},
output: (name, value) => {
Expand Down
37 changes: 30 additions & 7 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
* without touching the process.
*/

import { closeSync } from "node:fs";
import { Command, CommanderError, Option } from "commander";
import { ok } from "neverthrow";
import pc from "picocolors";
import {
describeProblem,
Expand Down Expand Up @@ -38,7 +40,14 @@ import {
once,
tokenValues,
} from "./inputs.js";
import { type CliIo, type CliStreams, cliIo, type MaskedStreams, maskedStreams } from "./io.js";
import {
type CliIo,
type CliStreams,
cliIo,
type MaskedStreams,
maskedStreams,
openSummaryFile,
} from "./io.js";

/** Every subcommand, in help order; the package smoke asserts the installed help names each. */
export const CLI_COMMANDS = [
Expand Down Expand Up @@ -101,14 +110,14 @@ export interface ProgramOptions {
readonly executeInit?: (cfg: InitConfig, io: Io) => Promise<Rendered>;
}

/** What every subcommand's action shares; `exit` records the code main() returns. */
/** What every subcommand's action shares; `exit` records the code main() returns, and `openIo` takes the descriptor a mode command holds on its `--summary` file. */
interface Wiring {
readonly host: CliHost;
readonly streams: MaskedStreams;
readonly bold: (text: string) => string;
readonly execute: (cfg: RunConfig, io: Io) => Promise<RunEnd>;
readonly executeInit: (cfg: InitConfig, io: Io) => Promise<Rendered>;
readonly openIo: (globals: Globals) => CliIo;
readonly openIo: (globals: Globals, summaryFd?: number) => CliIo;
readonly exit: (code: number) => void;
}

Expand Down Expand Up @@ -174,11 +183,23 @@ function addModeCommands(program: Command, wiring: Wiring): void {

command.action(async function (this: Command) {
const values = this.optsWithGlobals<Globals & Record<string, unknown>>();
const { io, flush } = wiring.openIo(values);
const summary =
values.summary === undefined ? ok(undefined) : openSummaryFile(values.summary);
if (summary.isErr()) {
// No run started, so this ends as a file-only command's failure does: the error line and the failed envelope.
const refused: Rendered = { code: 1, lines: [], json: failedEnvelope(summary.error) };
wiring.openIo(values).io.annotate("error", summary.error);
present(wiring.streams, refused, values);
wiring.exit(refused.code);
return;
}

const summaryFd = summary.value;
const { io, flush } = wiring.openIo(values, summaryFd);
const read = argvReader(mode, values);
// The envelope carries the fatal problem's text beside the outputs; the line itself is already on stderr.
let fatal: string | undefined;
const code = await parseConfig(read, wiring.host.env, CLI_CAPABILITIES).match(
const run = parseConfig(read, wiring.host.env, CLI_CAPABILITIES).match(
async (cfg) => {
const end = await wiring.execute(cfg, io);
fatal = end.fatal === undefined ? undefined : describeProblem(end.fatal);
Expand All @@ -189,6 +210,8 @@ function addModeCommands(program: Command, wiring: Wiring): void {
return failRun(io, problem);
},
);
// Nothing appends to the summary once the run has settled, whichever way it ends.
const code = await (summaryFd === undefined ? run : run.finally(() => closeSync(summaryFd)));
wiring.exit(code);
flush(fatal);
});
Expand Down Expand Up @@ -283,12 +306,12 @@ export function buildProgram(options: ProgramOptions): {
bold: paint.bold,
execute,
executeInit,
openIo: (globals) =>
openIo: (globals, summaryFd) =>
cliIo({
streams,
json: globals.json === true,
verbose: globals.verbose === true,
summaryFile: globals.summary,
summaryFd,
colors,
}),
exit: (code) => {
Expand Down
Loading
Loading