Skip to content

fix(action): keep settings text from issuing workflow commands, and open --summary before the run - #591

Merged
Vivswan merged 4 commits into
mainfrom
fix/cli-log-sinks
Nov 12, 2026
Merged

Vivswan merged 4 commits into
mainfrom
fix/cli-log-sinks

Conversation

@Vivswan

@Vivswan Vivswan commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Before

text writes   -> written as they came: a line break ends a log line, `::` can open a line, `##[` stands anywhere
                 (the `::` commands themselves already escaped %, CR, and LF)
--summary     -> the run's requests land -> open and append -> the open fails -> "stopped unexpectedly", exit 1
# the action, mode: check, one multi-repo target whose own settings file declares two labels:
#   "x\n::error file=README.md,line=1::spoofed"  and  "y##[error]spoofed"
1 | e2e-owner/pub: drift: labels[x
2 | ::error file=README.md,line=1::spoofed]: missing - declared in the settings file but not on the repo; apply will create it
3 | e2e-owner/pub: drift: labels[y##[error]spoofed]: missing - declared in the settings file but not on the repo; apply will create it
4 | result: drift
$ bun /repo/src/cli.ts apply --repository e2e-owner/e2e-repo --settings-file s.yml --json --summary no-such-dir/summary.md
{"result":"failed","problem":"github-settings-as-code stopped unexpectedly: Error: ENOENT: no such file or directory, open 'no-such-dir/summary.md'. Re-run with --verbose for the stack; if it recurs, file a bug with that output attached"}
# exit 1, and the mock had already answered  POST /repos/e2e-owner/e2e-repo/labels -> 201

$ GITHUB_ACTIONS=true bun /repo/src/cli.ts validate dup.yml --json
::error::dup.yml has malformed section entries: labels[1].name: "a##[error]b" names the same label as "a##[error]b" declared earlier; keep exactly one entry per label. Fix these values in the settings file (only the named keys are validated; extra fields pass through, except in closed sections and strict nested objects like actions.cache, which reject unrecognized keys)
{"result":"failed","file":"dup.yml","problem":"dup.yml has malformed section entries: labels[1].name: \"a##[error]b\" names the same label as \"a##[error]b\" declared earlier; keep exactly one entry per label. Fix these values in the settings file (only the named keys are validated; extra fields pass through, except in closed sections and strict nested objects like actions.cache, which reject unrecognized keys)"}
# dup.yml declares the label "a##[error]b" twice

$ GITHUB_ACTIONS=true bun /repo/src/cli.ts validate '::error::spoofed.yml'
::error::spoofed.yml is valid: 1 section declared (labels)
# a valid settings file with that name

A target repository's settings file could issue workflow commands in the admin's run.

After

masks         -> redacted first, on both faces, and only then the escapes below
io.log(line)  -> CR and LF written %0D and %0A -> one call, one line
`::`, `##[`   -> a leading `::` written %3A%3A, `##[` written %23%23[: at the action's log sink, and at the
                 CLI's stream boundary under a runner, for every chunk that is not a finished command
--summary     -> directories created, file opened for append -> ok:      the run, then the summary through that descriptor
                                                             -> refused: error naming --summary, exit 1, no request
1 | e2e-owner/pub: drift: labels[x%0A::error file=README.md,line=1::spoofed]: missing - declared in the settings file but not on the repo; apply will create it
2 | e2e-owner/pub: drift: labels[y%23%23[error]spoofed]: missing - declared in the settings file but not on the repo; apply will create it
3 | result: drift
$ bun /repo/src/cli.ts apply --repository e2e-owner/e2e-repo --settings-file s.yml --json --summary no-such-dir/summary.md
{"result":"applied","skipped-sections":[],"repos-result":{}}
# exit 0, no-such-dir/summary.md holds the run's summary

$ bun /repo/src/cli.ts apply --repository e2e-owner/e2e-repo --settings-file s.yml --json --summary reports
{"result":"failed","problem":"cannot write the summary to reports: Error: EISDIR: illegal operation on a directory, open 'reports'. Check that --summary names a writable path"}
# exit 1, the mock received no request (reports is a directory)

$ bun /repo/src/cli.ts apply --repository e2e-owner/e2e-repo --settings-file s.yml --json --summary summary.md
{"result":"failed","problem":"cannot write the summary to summary.md: Error: ENOENT: no such file or directory, open 'summary.md'. Check that --summary names a writable path"}
# exit 1, the mock received no request (summary.md links to missing/summary.md, and missing/ does not exist)

$ GITHUB_ACTIONS=true bun /repo/src/cli.ts validate dup.yml --json
::error::dup.yml has malformed section entries: labels[1].name: "a##[error]b" names the same label as "a##[error]b" declared earlier; keep exactly one entry per label. Fix these values in the settings file (only the named keys are validated; extra fields pass through, except in closed sections and strict nested objects like actions.cache, which reject unrecognized keys)
{"result":"failed","file":"dup.yml","problem":"dup.yml has malformed section entries: labels[1].name: \"a%23%23[error]b\" names the same label as \"a%23%23[error]b\" declared earlier; keep exactly one entry per label. Fix these values in the settings file (only the named keys are validated; extra fields pass through, except in closed sections and strict nested objects like actions.cache, which reject unrecognized keys)"}
# dup.yml declares the label "a##[error]b" twice

$ GITHUB_ACTIONS=true bun /repo/src/cli.ts validate '::error::spoofed.yml'
%3A%3Aerror::spoofed.yml is valid: 1 section declared (labels)
# a valid settings file with that name

Text from a settings file or from live GitHub state, printed by the action's or the CLI's own writers, can no longer be read by the runner as a workflow command: :: at the start of a line, ##[ anywhere.

Not covered: a dependency's own output, a marker split across two writes, a writer that deliberately bypasses the sink or stream.

Under a runner, --json text shows ##[ as %23%23[, and a :: inside it stays.

How

  • Masked values are redacted before any escape, so an escaped secret cannot slip past the runner's mask.
  • --summary is opened before the run, so a new file exists, empty, even on a refusal.
  • Library: @actions/core and commander export no escaper.

Proof

  • Tests: 158 pass, 0 fail in test/{cli,action,io}.
  • Re-expected: the unexpected-stop test now throws from the client factory, not through --summary.
Technical details
  • What the runner reads: ActionCommandManager tries the :: parser first, which trims leading whitespace and needs :: at the start, then the ##[ parser, which searches the whole line.
  • A finished command is a chunk the CLI's boundary built itself (::add-mask::, ::notice::, ::warning::, ::error::), and it passes whole.
  • ##[ inside a finished command stays as written: the runner takes that line as the :: command and never tries the other parser on it.
  • Leading whitespace is exactly the 25 characters .NET's Char.IsWhiteSpace accepts, which is what the runner's TrimStart removes.
  • A line starts at the start of a chunk or after CR or LF, where the runner's reader ends one. U+2028 and U+2029 start none.
  • The sink owns the rule, so no producer changed.
  • Where the rule sits: plainLine in src/action/io.ts for the action, withoutCommands in src/cli/actions.ts at the CLI's stream boundary.
  • The --summary file is appended to through the descriptor opened before the run, and its directories stay with it.
  • Lint: noConsole is on for src/** except src/action/io.ts, so a new direct console call fails bun run lint.
  • On a terminal nothing is rewritten for ##[ or ::.
  • A line break in a logged value is written %0D or %0A on a terminal too.
  • Line ends covered: LF, CRLF, and a lone CR, each on both sinks.
  • % stays in a log line, since only a command's data is decoded.
  • Masking order, action: the log sink now redacts the masks the action registered, then escapes.
  • Masking order, CLI: its log sink and its stream already redacted first, and tests now pin both.
  • Two copies of the rule: src/action/io.ts and src/cli/actions.ts, since the action layer imports only src/index.ts.
  • The white-space list is written once per face, and a test checks both against its own list over U+0000..U+FFFF.
  • Parity: test/cli/actions.test.ts holds one log line equal from both faces under a runner: the line-break escape at each sink, a leading :: and ##[ at the action's sink and at the CLI's boundary.
  • The summary descriptor is closed when the run settles, whichever way it ends.
  • Links, directories, read-only files, and unwritable parents behave as the operating system's open does.
  • init, validate, and permissions do not open or touch the --summary destination.
  • The runner's GITHUB_STEP_SUMMARY is used as before, by path, as the action uses it.
  • A refused --summary ends like an init failure: the error line, {"result":"failed","problem":...} under --json, exit 1, no output lines.
  • Recorded, not built: @actions/artifact passes a service message to raw core.info, a dependency's own output.
  • Recorded, not built: a marker split across two writes (## then [error]), which no writer does.
  • Recorded, not built: a test that pins the summary descriptor's lifetime.
  • Recorded, not built: --summary naming the same path as an input file, for example a missing --settings-file.
  • Recorded, not built: a race between the open and the append from another process.
  • Recorded, not built: --summary naming the same path as --rendered-file, where the rendered file replaces the path and the summary is lost.
  • e2e: bun test/e2e/run.ts --sections labels, 64 of 64.
  • Captured blocks: a scratch script ran the e2e runner's parseScenario and runScenario on the action bundle, and the CLI entry against the same mock, once on main and once on this branch.
  • Action scenario: inputs: {mode: check}, one repos entry e2e-owner/pub with the two labels in settings and live_state: {labels: []}.
  • CLI runs: startMockServer on a scenario with live_state: {labels: []}, s.yml declaring one label triage, GITHUB_API_URL set to the mock and GITHUB_TOKEN to the e2e token.
  • validate runs: the CLI entry in a scratch directory holding dup.yml and a valid settings file named ::error::spoofed.yml.
  • Action stdout shown without its ::add-mask:: and ::debug:: lines, behind a line-number gutter.
  • Docs: the --summary row, one runner-mode bullet, and one sentence on line breaks in docs/start/cli.md.
  • Files: src/{io.ts,action/io.ts}, src/cli/{actions.ts,io.ts,program.ts}, test/cli/{actions,io,program}.test.ts, docs/start/cli.md, biome.local.jsonc.

…the run

A line break in text a settings file or GitHub supplied ended the log line, and what followed opened the next one. A GitHub Actions runner reads a line that starts with two colons as a workflow command, so a label name in a target repository's own settings file could issue one from the admin's run. The action's log and the CLI's log now write CR and LF as %0D and %0A, the spelling annotations already use, so one log call is one line whatever its producer. The CLI redacts before it escapes, so a masked value that spans lines still matches.

--summary into a directory that did not exist threw when the summary was appended, after the run's requests had landed, and the run ended "stopped unexpectedly" with exit 1. The path is now settled before the run: its directories are created, as the rendered and snapshot files create theirs, and an append is proved possible without leaving a file behind. A path nothing can append to is refused before any request, naming --summary.

The test of the unexpected-stop path used that crash as its vehicle. It now reaches the same path through a client factory that throws.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 01:24
@github-actions

Copy link
Copy Markdown
Contributor

File size check

0 over a hard cap (fails), 6 warning(s).

File Size Tier Cap
src/engine/diff.ts:56 11 comment lines warn 10
src/github/api-error.ts:9 11 comment lines warn 10
src/problem.ts:73 18 comment lines warn 10
src/sections/contract/errors.ts:13 11 comment lines warn 10
src/sections/contract/module.ts:55 12 comment lines warn 10
src/sections/shared/list/decl.ts:265 14 comment lines warn 10

Split the file, wrap the line, shorten or exempt the comment, or list the path in .file-size-allow.local with a # reason.

5 managed file(s) skipped; repo-platform owns them.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Member shape check

4 finding(s).

File Line Rule
src/action/io.ts 24 one-line-comment
src/cli/actions.ts 12 one-line-comment
test/cli/actions.test.ts 45 one-line-comment
test/sections/contract/validate-hook.test.ts 80 all-or-none

Add the blank lines, make the comment one line directly above the item, comment every field or none, or list the path in .member-shape-allow.local with a # reason.

4 generated file(s) skipped.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Logged label names can inject GitHub Actions error annotations, and dangling summary symlinks can cause write failures after GitHub settings have already changed.

2 open issues
What changed in this PR

This PR hardens log output in the action and CLI and adds early validation of CLI summary paths to avoid discovering write failures after repository changes.

Changes:

  • Escape CR and LF in log messages, with CLI redaction performed first.
  • Prepare and probe explicit --summary paths before summary-producing commands run.
  • Add regression tests and document summary-path behavior.
File Description
test/​cli/​program.test.ts Tests summary preparation, early refusal, and unexpected-error reporting.
test/​cli/​io.test.ts Tests multiline redaction and summary-file probing.
test/​cli/​actions.test.ts Checks action/CLI log parity and line-break escaping.
src/​io.ts Documents the one-call, one-line logging contract.
src/​cli/​program.ts Settles explicit summary paths before starting commands.
src/​cli/​io.ts Adds summary-path probing and redaction-before-escaping.
src/​cli/​actions.ts Adds a line-break escaper reused by workflow-command formatting.
src/​action/​io.ts Escapes line breaks in action log output.
docs/​start/​cli.md Documents directory creation and early summary-path refusal.

Review effort: Balanced

Comment thread src/action/io.ts Resolved
Comment thread src/cli/io.ts Resolved
…ummary open from before the run

A GitHub Actions runner reads two forms as a workflow command: two colons at the start of a line, after leading whitespace, and the older form that opens with two hash signs and a bracket, which it finds anywhere in a line. Escaping line breaks alone left the second open, so a label name holding it still issued a command with no line break at all. The action's log sink now rewrites that marker as well. For the command line the rewrite sits at the stream boundary, under a runner only, on every chunk that is not a finished command, so the log, the debug trace, the JSON envelope, and any write added later are covered at one point. On a terminal the output is unchanged.

The --summary check created and removed a probe file, which needed an exclusive create, which needed an exception for links, and a link into a missing directory still failed after the run's requests had landed. It is replaced by the plain form: the parent directories are created, the path is opened for append before the run, and the summary is appended through that descriptor, which is closed when the run settles. A path the open refuses is refused before any request. The file therefore exists, empty, from the start of the run.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 02:07
@Vivswan Vivswan changed the title fix(cli): keep one log call on one line, and settle --summary before the run fix(action): keep settings text from issuing workflow commands, and open --summary before the run Nov 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the output-escaping and summary-file preflight changes.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

…t direct console calls

The guarantee: text from a settings file or from live GitHub state, printed by this action's or this command line's own writers, cannot be read by the Actions runner as a workflow command. The runner reads two colons at the start of a line, after leading whitespace, and the older hash-bracket marker anywhere in a line. Not covered: text a dependency prints itself, a marker split across two writes, and a writer that bypasses the sink or the stream on purpose.

Until now two colons at the start of a line were closed only by escaping line breaks in a log call, so a line that itself began with them passed: no producer writes one today, but a new producer, or a non-log write of the command line under a runner, would have. The action's log sink now rewrites a leading pair of colons, and the command line's stream boundary does the same for every line of every chunk that is not a finished command, beside the older marker it already rewrote. Leading whitespace is what the runner trims, the NEL character included.

A lint rule now refuses a direct console call anywhere in src except the action's sink, so a new writer cannot bypass the sink unnoticed. The docs say that a line break in a logged value is written in its percent form on a terminal too. The redundant type annotation on the summary open is gone, and the comment beside the descriptor's close states what holds for a refused run as well.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 02:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Logging escapes registered secrets before masking, allowing secret leakage, while summary-file creation can turn a missing settings input into a successful empty run.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

High severity Redact masked secrets before escaping log output

src/​action/​io.ts:64

Escaping the log before the runner masks it exposes registered secrets containing ##[. For example, actionsIo.mask("pw##[tag]123") followed by actionsIo.log("value=pw##[tag]123") emits value=pw%23%23[tag]123, which no longer matches the registered mask and reveals the secret through reversible escaping. Import redactRanges from ../index.js and pass redactRanges(line, actionsIo.masked()) to plainLine, so redaction happens before escaping.

Medium severity Reject summary paths that collide with settings inputs

src/​cli/​program.ts:187

Opening the summary can turn a missing settings input into a successful empty run when both flags name the same file.

With missing.yml absent, gsac render --settings-file missing.yml --rendered-file out.yml --summary missing.yml creates the missing input here. readSettingsFile() then reads an empty file, which parseSettingsDoc() converts to {}, so render succeeds instead of reporting the missing layer. Refuse summary/input path collisions, including aliases, before creating the summary so opening an output cannot manufacture its own input.

Review effort: Balanced

…ts and white space exactly

The last commit rewrote two things the runner does not read, and on the action it escaped a line before the runner could mask it.

A registered secret that holds the hash-bracket marker, a line break, or a leading pair of colons was escaped by the action's log sink, so the runner's mask no longer matched it and the secret printed in a reversible form. The sink now redacts the masks the action registered and only then escapes. The command line already redacted first, at its log sink and at its stream; tests now pin that order on both faces.

The command line's line-start rule used a multiline anchor, which in JavaScript also matches after U+2028 and U+2029, though the runner's reader ends a line at CR and LF only. A line now starts at the start of a chunk or after CR or LF. The leading white space was JavaScript's class plus NEL, one character wider than what the runner trims. It is now exactly the 25 characters .NET's Char.IsWhiteSpace accepts, written out on each face, and the test checks both against its own list over the whole Basic Multilingual Plane.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 03:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because summary-path collisions can turn missing settings into a successful empty run or silently discard the summary when it shares an output destination.

No open issues 🎉

Review effort: Balanced

@Vivswan
Vivswan marked this pull request as ready for review November 12, 2026 03:44
@Vivswan Vivswan added the merge-when-green Owner approved: merge once every gate is green label Nov 12, 2026
@Vivswan
Vivswan merged commit acb5ba4 into main Nov 12, 2026
34 checks passed
@Vivswan
Vivswan deleted the fix/cli-log-sinks branch November 12, 2026 04:44
This was referenced Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-when-green Owner approved: merge once every gate is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants