Skip to content

Silent I/O failure after getExecOutput() completes in GitHub Actions runtime #2303

@Wolfe-Jam

Description

@Wolfe-Jam

Description

After @actions/exec.getExecOutput() completes in a GitHub Actions Node.js action, all subsequent I/O operations (logging, filesystem writes) fail silently. No errors are thrown. The process continues executing but cannot produce any output.

This behavior does not occur in local Node.js execution. It is specific to the GitHub Actions runtime environment.

Reproduction

Minimal action code:

import * as exec from '@actions/exec';
import * as core from '@actions/core';
import * as fs from 'fs';

// This logs successfully
core.info('Before exec - this appears in the log');

const result = await exec.getExecOutput('npm', ['test'], {
  ignoreReturnCode: true
});

// This never appears in the log
core.info('After exec - this is silently lost');

// This file is never created
fs.writeFileSync('debug.txt', `Exit code: ${result.exitCode}`);

// result.stdout and result.stderr ARE populated correctly
// The subprocess ran fine. The calling context is broken.

What happens:

  • core.info() before exec: appears in log
  • exec.getExecOutput(): runs correctly, returns populated result
  • core.info() after exec: silently lost
  • fs.writeFileSync() after exec: file never created
  • No errors thrown. Exit code 0.

Observed Behavior

  1. Subprocess executes correctly (Jest runs, tests pass)
  2. result.stdout and result.stderr are populated
  3. All I/O after the exec call fails silently:
    • console.log - silent
    • console.error - silent
    • core.info - silent
    • core.debug - silent
    • fs.writeFileSync - file not created, no error thrown

Expected Behavior

Logging and filesystem operations should work normally after getExecOutput() returns.

Debugging Steps Taken

We tested 19 different methods across 16 versions to isolate the issue:

Method Result
console.log after exec Silent
console.error after exec Silent
core.info after exec Silent
Dual logging (core.info + console.log) Both silent
fs.writeFileSync to working directory File not created
fs.writeFileSync to process.cwd() File not created
fs.writeFileSync to os.tmpdir() File not created
fs.writeFileSync to 4 locations simultaneously No files found anywhere
Unconditional logs (no if/else) Silent
CHECKPOINT logs before and after exec Before: works. After: silent.
Fix this binding with arrow functions Not the issue
Switch exec.exec() to exec.getExecOutput() Same failure
Switch child_process.spawn to @actions/exec Same failure

Key finding (CHECKPOINT test): Placing core.info('CHECKPOINT 1') before the exec call and core.info('CHECKPOINT 2') after revealed that CHECKPOINT 1 appears in the log and CHECKPOINT 2 does not. The execution context becomes corrupted at the point of subprocess return.

Workaround

Separate test execution from result processing. Run the subprocess in a prior workflow step and read the output file:

# Step 1: Run in normal shell (I/O works fine)
- run: npm test 2>&1 | tee test-output.txt

# Step 2: Action reads file (no subprocess needed)
- uses: my-action@v2
  with:
    test-output-file: test-output.txt

This "pre-capture pattern" avoids the issue entirely by not spawning subprocesses inside the Node.js action.

Environment

  • Runner: ubuntu-latest
  • Node: 20 (via runs.using: 'node20' in action.yml)
  • @actions/exec: ^1.1.1
  • @actions/core: ^1.10.1
  • Subprocess: npm test (Jest)

Evidence

The complete debugging journey is documented across 28 commits:

Impact

This affects any GitHub Action that needs to:

  1. Run an external command via @actions/exec
  2. Then process the results (log, write files, set outputs)

This is a common pattern for test runners, linters, formatters, and build tools.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions