Skip to content

[REVIEW DRAFT] Windows container PATH fix (do not merge) + proposed upstream issue/PR#1

Closed
bugale wants to merge 1 commit into
mainfrom
fix/windows-container-path
Closed

[REVIEW DRAFT] Windows container PATH fix (do not merge) + proposed upstream issue/PR#1
bugale wants to merge 1 commit into
mainfrom
fix/windows-container-path

Conversation

@bugale

@bugale bugale commented Jun 14, 2026

Copy link
Copy Markdown
Owner

⚠️ Review draft — do not merge. This PR exists only inside your fork (bugale/runner) so you can review the fix and the proposed upstream texts. The upstream owner (sirredbeard) cannot see this. Nothing has been opened in sirredbeard/runner yet. Once you approve, I'll open the real issue + PR there.

The fix (what this branch changes)

Two files, ~9 lines (see the Files changed tab).

  • ContainerOperationProvider.cs — on Windows, when docker inspect .Config.Env yields no PATH (servercore/nanoserver/most Windows images), read the container's real PATH via docker exec <id> cmd /c echo %PATH%.
  • StepHost.cs — join the prepended dirs and the base PATH with Path.PathSeparator instead of a hard-coded POSIX :.

Why this, not just a case-insensitive PATH key

The downstream workaround notes a case-sensitive "PATH" lookup, but I confirmed empirically that the relevant Windows images (servercore:ltsc2025, nanoserver, the dotnet images, and the real builder image) declare no Path/PATH in .Config.Env at all — the real PATH only exists at runtime. A case-insensitive read wouldn't help; the base must be read live. This patch is a superset of the case fix and also covers the "image omits PATH" case.

Verification

  • dotnet build clean; DockerUtilL0 + StepHostL0 + ContainerOperationProviderL0 pass (61 tests).
  • End-to-end on the real builder container: broken -e PATH="<prepend only>" -> hcs::CreateProcess 0x2, exit 127; fixed -e PATH="<prepend>;<livePATH>" -> shell launches, prepend first, System32 present, exit 0.
  • No new tests added: the fork has no unit tests beyond actions/runner, and the fix keeps DockerUtil.cs/DockerUtilL0.cs untouched for clean rebases.

📋 Proposed UPSTREAM ISSUE (to open in sirredbeard/runner)

Title: Windows container: job — container PATH dropped after a step writes $GITHUB_PATH (shell fails to launch, exit 126/127)

Summary

In a Windows job-level container: job, as soon as any step writes to $GITHUB_PATH (directly, or via a setup-* action), every subsequent in-container step fails to launch its shell:

container <id> encountered an error during hcs::System::CreateProcess: powershell ...:
The system cannot find the file specified. (0x2)

and the step exits 126/127.

Root cause

When a step appends to $GITHUB_PATH, ContainerStepHost.ExecuteAsync re-runs the in-container command with an explicit PATH override:

docker exec ... -e PATH="<prepended-dirs>:<ContainerRuntimePath>" <id> <shell> ...

ContainerRuntimePath is captured once at container start in ContainerOperationProvider from
docker inspect --format "{{range .Config.Env}}{{println .}}{{end}}" <id>, parsed by DockerUtil.ParsePathFromConfigEnv.

Two problems surface on Windows:

  1. .Config.Env has no PATH. Typical Windows images (mcr.microsoft.com/windows/servercore, nanoserver, even the dotnet images) do not declare Path/PATH in their image config. A Windows container's real PATH (System32, PowerShell, …) is provided by the OS at runtime and is not visible via docker inspect .Config.Env. So ParsePathFromConfigEnv returns empty and ContainerRuntimePath is empty.
  2. POSIX separator. The override joins the prepended dirs and the base with a hard-coded :, which is wrong on Windows (separator is ;, and drive letters contain :).

With an empty base, the override collapses to -e PATH="<prepended-dirs>" — the container's System32/PowerShell are gone, so the next step's shell can't be found.

Reproduction

docker inspect <id> --format "{{range .Config.Env}}{{println .}}{{end}}" shows no PATH, while docker exec <id> cmd /c echo %PATH% shows the real one. Replaying the exact command line the runner produces against a real Windows container:

# broken (empty base — what the runner emits today):
docker exec -e PATH="C:\probe" <id> powershell -NoProfile -Command "echo hi"
#   -> hcs::System::CreateProcess ...: The system cannot find the file specified. (0x2), exit 127

# with the container's real PATH appended and ';' separator:
docker exec -e PATH="C:\probe;C:\Windows\system32;C:\Windows;..." <id> powershell -NoProfile -Command "echo hi"
#   -> hi, exit 0

Minimal workflow that triggers it:

jobs:
  repro:
    runs-on: [self-hosted, windows]
    container: mcr.microsoft.com/windows/servercore:ltsc2025
    defaults: { run: { shell: powershell } }
    steps:
      - run: Add-Content -Path $env:GITHUB_PATH -Value 'C:\probe'
      - run: Write-Host "next step's shell launches: $env:PATH"   # fails today

Suggested fix

  • In ContainerOperationProvider, when .Config.Env yields no PATH on Windows, read the container's real PATH (docker exec <id> cmd /c echo %PATH%).
  • In ContainerStepHost.ExecuteAsync, join with Path.PathSeparator instead of :.

Small patch (2 files, ~9 lines); PR to follow.


📋 Proposed UPSTREAM PR description (to open in sirredbeard/runner)

Title: Fix Windows container PATH being dropped after a $GITHUB_PATH write

Problem

On a Windows container: job, after any step writes $GITHUB_PATH, every later in-container step fails to launch its shell:

hcs::System::CreateProcess: powershell ...: The system cannot find the file specified. (0x2)   # exit 126/127

When a step appends to $GITHUB_PATH, ContainerStepHost.ExecuteAsync re-execs with an explicit override docker exec -e PATH="<prepend>:<ContainerRuntimePath>". ContainerRuntimePath is read once at container start from docker inspect .Config.Env (DockerUtil.ParsePathFromConfigEnv). On Windows that base is empty: typical Windows images (servercore, nanoserver, the dotnet images) don't declare Path in .Config.Env — a Windows container's real PATH only exists at runtime. The override therefore drops System32/PowerShell, and the segments were joined with a POSIX :.

Fix

  • ContainerOperationProvider: when .Config.Env has no PATH on Windows, read the container's real PATH via docker exec <id> cmd /c echo %PATH%.
  • ContainerStepHost.ExecuteAsync: join with Path.PathSeparator instead of :.

Testing

  • dotnet build clean; existing DockerUtilL0, StepHostL0, ContainerOperationProviderL0 tests pass (61).
  • End-to-end against a real Windows container: the exact broken command line (-e PATH="<prepend only>") fails with hcs::CreateProcess 0x2 / exit 127; with the container's live PATH appended and ; separator the shell launches, the prepended dir stays first, and System32 is present (exit 0).

Linux is unaffected: Path.PathSeparator is : there, and the live-PATH read is Windows-only and only triggers when .Config.Env has no PATH.

On Windows, docker inspect .Config.Env rarely contains the container's PATH
(servercore/nanoserver and most images don't declare it), so
ParsePathFromConfigEnv returns empty. The later docker exec -e PATH= override
then replaces the container PATH with only the prepended dirs -- System32 and
PowerShell vanish and the next step's shell can't launch (hcs::CreateProcess
0x2, exit 126/127). PATH segments were also joined with a POSIX ':' separator.

Read the container's real PATH at startup when .Config.Env has none, and join
with Path.PathSeparator.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bugale

bugale commented Jun 14, 2026

Copy link
Copy Markdown
Owner Author

@bugale bugale closed this Jun 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant