Skip to content
WSLDeep Dive Published Updated 10 min readViews unavailable

GitHub Actions for WSL: A Reliable Windows-to-Linux Integration Test Runner

Design repeatable WSL integration CI with a Windows runner, Ubuntu's WSL actions, serialized VM lifecycle jobs, and Linux-side test evidence.

Most Linux unit and integration tests belong on a normal Linux CI runner. A WSL-specific test is different: it must verify the Windows/Linux boundary itself, such as process interop, Windows-mounted files, WSLg availability, the WSL kernel, or a workflow that launches Linux from Windows. Ubuntu publishes GitHub Actions for running steps inside Ubuntu on WSL, along with a reference architecture for placing a runner on a Windows VM. The design is not “choose a Windows hosted runner and call wsl.exe.” The runner’s logon context and lifetime determine whether the Store-delivered WSL application is available.

Canonical’s documented workflow uses a Windows machine with WSL and a GitHub self-hosted runner started as a command-line application in a logged-in user session. The example places that runner on an Azure VM, starts and deallocates the VM from separate workflow jobs, and serializes access so concurrent workflows do not race over one distro or machine. The same architecture can use another Windows host provider, but its availability, startup, and lifecycle need an equivalent design.

The workflow and PowerShell examples here target Windows, GitHub Actions, and Ubuntu on WSL. They cannot be executed on the macOS authoring host; validate them on a disposable Windows/WSL runner before relying on the pipeline.

Decide whether the test actually needs WSL

Do not move all Linux CI to WSL by default. A regular Ubuntu runner is simpler for tests that only need a Linux kernel and userland. Use WSL integration CI when the test assertion depends on Windows as the host: invoking Windows executables from Ubuntu, checking DrvFs behavior, validating a Windows-side WSL command, testing Linux GUI integration, or confirming a specific Ubuntu-on-WSL first-run flow.

Keep those tests as a separate job or workflow with a narrow test suite. This gives failures a clear meaning: a regular Linux failure is about the software under test; a WSL integration failure may also involve the Windows build, WSL package, distro registration, or runner session. Record those host facts with the test result instead of letting an opaque “Linux CI failed” message cover several independent layers.

Ubuntu’s WSL GitHub Actions reference includes actions for installing/updating WSL, checking out a repository inside a WSL distribution, and executing Bash commands in that distribution. The checkout action clones to the Linux filesystem path you provide; use ordinary actions/checkout instead when the project should remain on the Windows filesystem. This distinction is material for tests that measure filesystem behavior or rely on Linux file metadata.

Build the runner around the WSL session model

Canonical documents that Windows GitHub-hosted runners do not support the Store version of WSL in its expected user context: the runner is a service and not in an interactive user session, whereas the Store application requires a logged-in user. The Ubuntu guidance uses a Windows VM, configures automatic logon, installs the runner, and starts its run.cmd command from the user’s Startup folder rather than registering the runner as a service.

Treat that as an architecture constraint, not a retryable test failure. A runner process that starts before the WSL user session, a VM that is powered off, or a runner configured as a service can leave jobs queued or make wsl.exe behave differently from an interactive developer environment. Choose a Windows image and WSL package that support the integration you intend to test, keep the VM’s runner registration stable, and monitor runner availability separately from test health.

An example GitHub Actions job using Ubuntu’s actions looks like this:

name: Ubuntu on WSL integration

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: wsl-integration-runner
  cancel-in-progress: false

jobs:
  test-in-wsl:
    runs-on: [self-hosted, windows, x64, wsl]
    timeout-minutes: 45
    steps:
      - name: Install or update WSL and Ubuntu
        uses: Ubuntu/WSL/.github/actions/wsl-install@main
        with:
          distro: Ubuntu-24.04

      - name: Check out the repository in Ubuntu
        uses: Ubuntu/WSL/.github/actions/wsl-checkout@main
        with:
          distro: Ubuntu-24.04
          working-dir: /tmp/wsl-ci

      - name: Run Linux integration tests
        uses: Ubuntu/WSL/.github/actions/wsl-bash@main
        with:
          distro: Ubuntu-24.04
          working-dir: /tmp/wsl-ci
          exec: |
            set -euo pipefail
            printf 'kernel: '; uname -r
            printf 'user: '; id
            ./ci/test-wsl-integration.sh

The action names and inputs follow Ubuntu’s published reference. The distro value must match an installed WSL distribution name. The action documentation shows branch-based references for readability; for a controlled pipeline, choose and record the source revision of the actions that you have validated. A job-level timeout-minutes prevents a stuck WSL command from occupying the runner indefinitely. The concurrency group is intentionally static for a single shared runner: only one workflow uses the Windows host at a time, and a newer commit does not cancel a workflow halfway through cleanup.

Do not assume the YAML listing alone makes the runner available. The custom wsl label must be assigned to the registered self-hosted runner, and the runner must be online in the user session before the job can start. If one Windows machine is shared among several workflows, the concurrency key needs to be identical across all those workflows. Per-workflow concurrency groups do not serialize jobs across separate files if their group values differ.

Start and stop an Azure VM around the integration job

For a VM that should not remain allocated between runs, Canonical’s reference uses three dependent jobs: start the Windows VM, execute the WSL test job on the self-hosted runner, and deallocate the VM even if the test job fails. The remote jobs run on a normal Ubuntu-hosted runner; the middle job is routed to the Windows runner. The middle job cannot run until that self-hosted runner connects after VM startup, so allow for Windows boot, interactive logon, Startup-folder processing, and WSL readiness.

jobs:
  start-windows-vm:
    runs-on: ubuntu-latest
    steps:
      - name: Authenticate to Azure
        uses: azure/login@v3
        with:
          creds: ${{ secrets.AZURE_VM_CREDS }}
      - name: Start integration host
        run: az vm start --name "$VM_NAME" --resource-group "$RESOURCE_GROUP"

  test-in-wsl:
    needs: start-windows-vm
    runs-on: [self-hosted, windows, x64, wsl]
    steps:
      - name: Run the WSL integration workflow
        run: echo "Replace with the Ubuntu WSL actions job steps"

  stop-windows-vm:
    if: ${{ always() }}
    needs: [start-windows-vm, test-in-wsl]
    runs-on: ubuntu-latest
    steps:
      - name: Authenticate to Azure
        uses: azure/login@v3
        with:
          creds: ${{ secrets.AZURE_VM_CREDS }}
      - name: Deallocate integration host
        run: az vm deallocate --name "$VM_NAME" --resource-group "$RESOURCE_GROUP"

This is a job-graph pattern, not a drop-in workflow: define VM_NAME and RESOURCE_GROUP in the workflow or job environment, use the Azure login configuration supported by the version of the action you select, and put the previous WSL action steps in the middle job. The if: always() on the final job ensures cleanup is considered when an earlier job fails. Keep the same concurrency policy around the entire start/test/stop sequence so one run cannot deallocate a VM another run is using.

The VM being “started” is not the same as the WSL distribution being ready. The runner can connect before the distro’s package installation or first-run initialization has completed. In the WSL test job, use the WSL actions to install/update the distro and run a bounded readiness command before invoking project tests. Keep those readiness checks explicit; do not hide a five-minute sleep that either wastes time or still misses a slow first boot.

Keep repository and test state on the intended filesystem

Choose a checkout location based on what the test proves. A repository checked out under /tmp or the Linux home directory exercises the distro’s Linux filesystem; a path under /mnt/c exercises DrvFs and Windows-side file access. Those are different test environments with different performance and metadata semantics. If a regression report concerns Windows-mounted files, make that path explicit in a separate test rather than silently changing the checkout location of every job.

The Ubuntu wsl-checkout action supports optional submodule and token inputs. Public repositories do not need a repository credential for ordinary checkout. For private repositories, follow the action’s current documentation and GitHub token guidance; Ubuntu warns that an explicitly supplied token may remain in local Git configuration after the job. Do not print the resulting Git configuration as a test artifact. This is a lifecycle concern specific to how the action performs checkout, not a reason to pass unnecessary credentials to every workflow.

Treat the WSL distro and its home directory as persistent runner state unless the workflow explicitly cleans or recreates them. Package upgrades, stale build output, a modified default user, and cloud-init first-run state can all make successive runs differ. Prefer job-specific work directories, bounded cleanup, and deliberate distro reset procedures. If you use wsl --unregister as cleanup, remember that it deletes that distro’s filesystem; never point the cleanup command at a developer’s working distribution or a shared long-lived environment.

Diagnose the four failure layers

First inspect GitHub’s runner state. A job stuck in “waiting for a runner” has not reached WSL; check the custom labels, runner process, VM power state, and interactive session. If the job starts but WSL installation fails, record wsl.exe --version, wsl.exe --status, and wsl.exe --list --verbose on the Windows host. If the distro launches but a Linux command fails, run the exact command through the documented WSL Bash action and capture its exit status and stdout/stderr.

For a failure tied to first-boot provisioning, collect cloud-init status and output logs inside the distro. For a path-sensitive failure, log the working directory and filesystem type (findmnt -T .) rather than merely printing a Windows and Linux path that look similar. For an interop test, assert the expected executable and output instead of treating a successful wsl.exe invocation as proof that the Windows/Linux process handoff worked.

When the test fails intermittently, preserve one coherent run’s evidence before restarting the host. A restart may clear a WSL VM state that would help isolate whether the problem was a runner-session issue, distro initialization, or a kernel-level failure. Keep host logs and Linux logs adjacent to the job identifier, and redact secrets or user data before retaining artifacts.

Define acceptance criteria before scaling

A production-quality WSL CI gate should prove more than “the command returned zero.” Record the Windows build, WSL version, distribution name/version, kernel release, runner label, checkout location, and whether the test was intended to cover interop or DrvFs. Verify that the runner is online in the intended user session, the expected distro starts, the repository lands on the intended filesystem, and the Linux-side test script runs to completion with its actual exit code preserved.

Exercise at least one clean first install, one warm subsequent run, a failing test that still triggers VM deallocation, and an interrupted workflow. Confirm that two workflows cannot concurrently mutate the same WSL instance. Measure total duration across VM startup, runner registration, distro readiness, test execution, and deallocation; that breakdown shows whether the cost is WSL setup or the test suite itself.

WSL-specific CI is valuable when Windows is part of the product boundary. A Windows self-hosted runner plus Ubuntu’s WSL actions gives the pipeline explicit control over that boundary, while separate Linux jobs can continue to cover ordinary Linux behavior. Keep those jobs distinct, make host lifecycle deterministic, and capture enough evidence to tell a WSL defect from a runner that never entered the right session.

Related:

Sources:

Comments