Skip to content

Commit 4132254

Browse files
authored
Merge pull request #48 from retailnext/copilot/add-hide-outputs-variable
Add `hide_outputs` input to suppress command output from Actions log
2 parents 24d14c7 + cfccc53 commit 4132254

File tree

7 files changed

+132
-14
lines changed

7 files changed

+132
-14
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ The outputs `stdout` and `stderr` have been replaced with `stdout_file` and
2424
- Capture standard output and standard error to temporary files
2525
- Output file paths available as action outputs
2626
- Stream output in real-time to the workflow logs
27+
- Optionally hide outputs from the workflow log to protect sensitive data
2728
- Forward signals (SIGINT, SIGTERM, SIGQUIT, SIGHUP, SIGPIPE, SIGABRT) to the
2829
running command
2930
- Commands are executed directly without a shell (no shell operators like `|`,
@@ -76,6 +77,17 @@ be considered successful. For example, some linters return specific exit codes
7677
for warnings vs errors, or you may want to accept multiple exit codes as valid
7778
outcomes.
7879

80+
### `hide_outputs`
81+
82+
**Optional** When set to `"true"`, the stdout and stderr from the command are
83+
only written to the respective output files and are not written to the
84+
stdout/stderr of the action itself. Default is `"false"`.
85+
86+
This is useful for commands that emit sensitive data (e.g., secrets, tokens) to
87+
their output. The outputs are hidden from the GitHub Actions log but remain
88+
available to subsequent workflow steps via the `stdout_file` and `stderr_file`
89+
output paths.
90+
7991
## Outputs
8092

8193
### `stdout_file`
@@ -159,3 +171,19 @@ The exit code of the executed command (as a string).
159171
# Accept 0, any code from 10-15, and 20 as success
160172
success_exit_codes: '0,10-15,20'
161173
```
174+
175+
### Hide sensitive outputs from the log
176+
177+
```yaml
178+
- name: Fetch Secret Data
179+
id: fetch
180+
uses: retailnext/exec-action@main
181+
with:
182+
command: 'my-tool --output-secret'
183+
hide_outputs: 'true'
184+
185+
- name: Process Secret Data
186+
run: |
187+
# The output is hidden from the log but available via the file path
188+
process-data < "${{ steps.fetch.outputs.stdout_file }}"
189+
```

__tests__/main.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,45 @@ describe('main.ts', () => {
110110
expect(core.setFailed).not.toHaveBeenCalled()
111111
})
112112

113+
it('Hides outputs from process streams when hide_outputs is true', async () => {
114+
core.getInput.mockImplementation((name: string) => {
115+
if (name === 'command') return 'echo "Hello World"'
116+
if (name === 'success_exit_codes') return '0'
117+
if (name === 'hide_outputs') return 'true'
118+
return ''
119+
})
120+
121+
await run()
122+
123+
// Verify outputs were still set with file paths
124+
expect(core.setOutput).toHaveBeenCalledWith(
125+
'stdout_file',
126+
expect.stringMatching(/exec-.*\.stdout$/)
127+
)
128+
expect(core.setOutput).toHaveBeenCalledWith(
129+
'stderr_file',
130+
expect.stringMatching(/exec-.*\.stderr$/)
131+
)
132+
expect(core.setOutput).toHaveBeenCalledWith('exit_code', '0')
133+
134+
// Verify the action did not fail
135+
expect(core.setFailed).not.toHaveBeenCalled()
136+
})
137+
138+
it('Does not hide outputs when hide_outputs is false', async () => {
139+
core.getInput.mockImplementation((name: string) => {
140+
if (name === 'command') return 'echo "Hello World"'
141+
if (name === 'success_exit_codes') return '0'
142+
if (name === 'hide_outputs') return 'false'
143+
return ''
144+
})
145+
146+
await run()
147+
148+
// Verify the action did not fail
149+
expect(core.setFailed).not.toHaveBeenCalled()
150+
})
151+
113152
it('Handles execution errors', async () => {
114153
// Use parseCommand with invalid input to trigger an error
115154
core.getInput.mockImplementation((name: string) => {
@@ -308,6 +347,23 @@ describe('main.ts', () => {
308347
expect(stdoutContent.length).toBeGreaterThan(0)
309348
})
310349

350+
it('Does not forward outputs to process streams when hideOutputs is true', async () => {
351+
const result = await executeCommand(
352+
'sh -c "echo stdout message && echo stderr message >&2"',
353+
{ hideOutputs: true }
354+
)
355+
356+
expect(result.exitCode).toBe(0)
357+
358+
// Verify stdout file still has the output content
359+
const stdoutContent = await readFile(result.stdoutFile, 'utf-8')
360+
expect(stdoutContent).toContain('stdout message')
361+
362+
// Verify stderr file still has the output content
363+
const stderrContent = await readFile(result.stderrFile, 'utf-8')
364+
expect(stderrContent).toContain('stderr message')
365+
})
366+
311367
it('Captures both stdout and stderr to separate files', async () => {
312368
const result = await executeCommand(
313369
'sh -c "echo stdout message && echo stderr message >&2"'

action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ inputs:
1818
(e.g., "0,1,2") or ranges (e.g., "0-2,5,10-15"). Default is "0".
1919
required: false
2020
default: '0'
21+
hide_outputs:
22+
description: >
23+
When set to true, stdout and stderr from the command are only written to
24+
the respective output files and are not written to the stdout/stderr of
25+
the action itself. This hides the outputs from the GitHub Actions log
26+
while still making them available to subsequent workflow steps via the
27+
output files. Default is "false".
28+
required: false
29+
default: 'false'
2130

2231
# Define your outputs here.
2332
outputs:

badges/coverage.svg

Lines changed: 1 addition & 1 deletion
Loading

dist/index.js

Lines changed: 16 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,18 @@ export async function run(): Promise<void> {
1515
try {
1616
const command: string = core.getInput('command', { required: true })
1717
const successExitCodesInput: string = core.getInput('success_exit_codes')
18+
const hideOutputs: boolean =
19+
core.getInput('hide_outputs').toLowerCase() === 'true'
1820

1921
core.debug(`Executing command: ${command}`)
2022
core.debug(`Success exit codes: ${successExitCodesInput}`)
23+
core.debug(`Hide outputs: ${hideOutputs}`)
2124

2225
// Parse success exit codes
2326
const successExitCodes = parseSuccessExitCodes(successExitCodesInput)
2427

2528
// Execute the command and capture outputs
26-
const result = await executeCommand(command)
29+
const result = await executeCommand(command, { hideOutputs })
2730

2831
// Set outputs for other workflow steps to use
2932
core.setOutput('stdout_file', result.stdoutFile)
@@ -204,13 +207,21 @@ function setupSignalHandlers(child: ReturnType<typeof spawn>): () => void {
204207
* Execute a command and capture its output to files.
205208
*
206209
* @param command The command to execute.
210+
* @param options Optional execution options.
211+
* @param options.hideOutputs When true, stdout and stderr are only written to
212+
* files and are not forwarded to process.stdout/process.stderr.
207213
* @returns A promise that resolves with file paths and exit code.
208214
*/
209-
export async function executeCommand(command: string): Promise<{
215+
export async function executeCommand(
216+
command: string,
217+
options: { hideOutputs?: boolean } = {}
218+
): Promise<{
210219
stdoutFile: string
211220
stderrFile: string
212221
exitCode: number
213222
}> {
223+
const { hideOutputs = false } = options
224+
214225
// Parse command into executable and arguments
215226
// Simple parsing that splits on whitespace while respecting quoted strings
216227
const args = parseCommand(command)
@@ -278,20 +289,24 @@ export async function executeCommand(command: string): Promise<{
278289
checkIfComplete()
279290
})
280291

281-
// Pipe stdout to both file and process.stdout
292+
// Pipe stdout to file, and optionally to process.stdout
282293
// By default, stream.end() is called on the destination when source emits 'end'
283294
if (child.stdout) {
284295
child.stdout.pipe(stdoutFileStream)
285-
child.stdout.pipe(process.stdout)
296+
if (!hideOutputs) {
297+
child.stdout.pipe(process.stdout)
298+
}
286299
} else {
287300
// No stdout, manually end the stream
288301
stdoutFileStream.end()
289302
}
290303

291-
// Pipe stderr to both file and process.stderr
304+
// Pipe stderr to file, and optionally to process.stderr
292305
if (child.stderr) {
293306
child.stderr.pipe(stderrFileStream)
294-
child.stderr.pipe(process.stderr)
307+
if (!hideOutputs) {
308+
child.stderr.pipe(process.stderr)
309+
}
295310
} else {
296311
// No stderr, manually end the stream
297312
stderrFileStream.end()

0 commit comments

Comments
 (0)