From 25779b119c3c67689ec2c01756ecd423d1f03fd2 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 01:19:48 +0300 Subject: [PATCH 01/13] feat!: replace Vagrant/libvirt image with dockur/windows compose setup Runs the Windows Server 2022 VM on dockurr/windows instead of the custom Vagrant + libvirt image. Windows is installed once from the evaluation ISO and persisted in ./storage; oem/install.ps1 installs the toolchain and registers the runners at the end of the unattended install. - Remove Dockerfile, Vagrantfile and the image release workflow - Add oem/install.bat + oem/install.ps1 (service or interactive runners, configurable VS edition/workloads, -RunnersOnly re-registration) - Settings reach the VM through .env mounted into the dockur shared folder - Lint: compose config, PowerShell parse + PSScriptAnalyzer, actionlint - Dependabot tracks the dockurr/windows tag - README rewritten with deprecation notice and 1.x migration guide --- .env.example | 54 ++++- .github/PSScriptAnalyzerSettings.psd1 | 13 ++ .github/dependabot.yml | 4 +- .github/workflows/ci.yml | 60 ----- .github/workflows/lint.yml | 18 +- .gitignore | 5 +- Dockerfile | 19 -- README.md | 169 ++++++++------ Vagrantfile | 88 -------- docker-compose.yml | 53 +++-- oem/install.bat | 8 + oem/install.ps1 | 309 ++++++++++++++++++++++++++ 12 files changed, 524 insertions(+), 276 deletions(-) create mode 100644 .github/PSScriptAnalyzerSettings.psd1 delete mode 100644 .github/workflows/ci.yml delete mode 100644 Dockerfile delete mode 100644 Vagrantfile create mode 100644 oem/install.bat create mode 100644 oem/install.ps1 diff --git a/.env.example b/.env.example index e1fb4c2..6a900b2 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Copy to .env and fill in. .env is git-ignored. +# +# Compose reads this file for the ${...} values in docker-compose.yml and also +# bind-mounts it into the VM's shared folder, where oem/install.ps1 reads it. +# Keep one KEY=VALUE per line, comments on their own lines, no "$" in values. -# --- Runner registration --------------------------------------------------- +# --- Runner registration ---------------------------------------------------- # Org: https://github.com/ # Repo: https://github.com// RUNNER_URL= @@ -13,13 +17,43 @@ PAT= # Number of runners registered from this VM RUNNERS=2 -# Optional overrides of the image defaults +# Pin an actions/runner release; empty means the latest release at install time # GITHUB_RUNNER_VERSION=2.338.0 -# GITHUB_RUNNER_NAME=windows_x64_vagrant -# GITHUB_RUNNER_LABELS=windows,win_x64,windows_x64,windows_vagrant_action - -# --- VM sizing ------------------------------------------------------------- -# MEMORY in MiB, DISK_SIZE in GiB -MEMORY=8000 -CPU=4 -DISK_SIZE=100 + +# Runner name prefix and labels +# GITHUB_RUNNER_NAME=windows_x64_dockur +# GITHUB_RUNNER_LABELS=windows,win_x64,windows_x64,windows_dockur_action +# GITHUB_RUNNER_GROUP= + +# Middle part of the runner name (__). Defaults to the +# Windows computer name, which dockur picks at random on install. Set it when +# you run several VMs against the same org or want names that survive a wipe. +# RUNNER_HOST_ID= + +# service (default): Windows services that start with the VM, no desktop. +# interactive: scheduled tasks in the auto-logon desktop session, for jobs +# that drive GUI tooling. +# RUNNER_MODE=service + +# Account the runner services run as. Default is the Windows user below. +# Built-in accounts need no password, e.g. NT AUTHORITY\NETWORK SERVICE +# RUNNER_SERVICE_ACCOUNT= + +# --- Toolchain -------------------------------------------------------------- +# Git, jq, 7-Zip, PowerShell 7 and Chocolatey are always installed. +# INSTALL_VISUAL_STUDIO=true +# enterprise | professional | community | buildtools (see licensing note in README) +# VS_EDITION=enterprise +# Comma-separated workload ids. For buildtools use Microsoft.VisualStudio.Workload.VCTools +# VS_WORKLOADS=Microsoft.VisualStudio.Workload.NativeDesktop +# INSTALL_RTOOLS=true + +# --- VM (dockur/windows) ---------------------------------------------------- +# Windows release; 2022 = Windows Server 2022 evaluation +VERSION=2022 +RAM_SIZE=8G +CPU_CORES=4 +DISK_SIZE=100G +# Local administrator created by the unattended install; also the RDP login +WIN_USERNAME=Docker +WIN_PASSWORD=admin diff --git a/.github/PSScriptAnalyzerSettings.psd1 b/.github/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..afc0108 --- /dev/null +++ b/.github/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,13 @@ +@{ + Severity = @('Error', 'Warning') + ExcludeRules = @( + # Console output of a one-shot provisioning script; the transcript captures it. + 'PSAvoidUsingWriteHost', + # Microsoft's documented one-liners for install-powershell.ps1 and Chocolatey. + 'PSAvoidUsingInvokeExpression', + # Install-Runners / Install-BaseTools read better plural. + 'PSUseSingularNouns', + # Internal helpers of a non-interactive script; -WhatIf is meaningless here. + 'PSUseShouldProcessForStateChangingFunctions' + ) +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0dbf6e4..dcd732d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,8 +9,8 @@ updates: patterns: - "*" - # Tracks the base image tag in the Dockerfile - - package-ecosystem: docker + # Tracks the dockurr/windows tag in docker-compose.yml + - package-ecosystem: docker-compose directory: / schedule: interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 9ad8601..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Windows custom runner - -on: - release: - types: [published] - -permissions: - contents: read - packages: write - -jobs: - publish-image: - runs-on: ubuntu-latest - name: publish image - steps: - - name: Remove unnecessary files - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - - - name: Checkout current repository - uses: actions/checkout@v7 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v6 - with: - # list of Docker images to use as base name for tags - images: | - ghcr.io/${{ github.repository }} - docker.io/${{ github.repository }} - # generate Docker tags based on the following events/attributes - tags: | - type=semver,pattern={{version}} - type=raw,value=latest - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Login to Github packages - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to Dockerhub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_REGISTRY_USERNAME }} - password: ${{ secrets.DOCKERHUB_REGISTRY_PASSWORD }} - - - name: Build and push - uses: docker/build-push-action@v7 - with: - context: . - file: Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8a80a1f..1d7a522 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,13 +16,19 @@ jobs: - name: Checkout current repository uses: actions/checkout@v7 - - name: Dockerfile (hadolint) - uses: hadolint/hadolint-action@v3.5.0 - with: - dockerfile: Dockerfile + - name: docker-compose.yml + run: cp .env.example .env && docker compose config -q - - name: Vagrantfile (Ruby syntax) - run: ruby -c Vagrantfile + - name: install.ps1 (parse + PSScriptAnalyzer) + shell: pwsh + run: | + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile("$PWD/oem/install.ps1", [ref]$null, [ref]$errors) | Out-Null + if ($errors) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 } + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + $findings = Invoke-ScriptAnalyzer -Path oem/install.ps1 -Settings .github/PSScriptAnalyzerSettings.psd1 + $findings | Format-Table -AutoSize + if ($findings) { exit 1 } - name: Workflows (actionlint) uses: raven-actions/actionlint@v2 diff --git a/.gitignore b/.gitignore index bd99fe3..4eb04f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Local configuration (contains PAT/TOKEN) .env -# Vagrant state -.vagrant/ +# VM disk, install ISO and the shared folder +storage/ +shared/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0eda78f..0000000 --- a/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM ghcr.io/vaggeliskls/windows-in-docker-container:1.0.1 - -LABEL org.opencontainers.image.source="https://github.com/vaggeliskls/windows-github-custom-runner" \ - org.opencontainers.image.description="Self-hosted GitHub Actions runners on a Windows VM (Vagrant + libvirt) inside a Linux container" - -# GitHub Actions runner defaults. All of these can be overridden at runtime via .env; -# the download URL is derived from GITHUB_RUNNER_VERSION inside the Vagrantfile. -ENV GITHUB_RUNNER_VERSION=2.338.0 -ENV GITHUB_RUNNER_NAME=windows_x64_vagrant -ENV GITHUB_RUNNER_LABELS=windows,win_x64,windows_x64,windows_vagrant_action - -# Provision elevated and in an interactive session so runners can drive GUI tooling -ENV PRIVILEGED=true -ENV INTERACTIVE=true - -# Replace the base image's generic Vagrantfile with the runner one. -# The base image's /app/startup.sh (libvirtd startup, KVM/QEMU fallback, graceful halt) is reused as-is. -COPY Vagrantfile /app/Vagrantfile diff --git a/README.md b/README.md index 3c98227..4f96e79 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,149 @@ # 🏃 Windows Github Custom Runner -Explore an innovative, efficient, and cost-effective approach to deploying a custom GitHub Runner that runs in a containerized Windows OS (x64) environment on a Linux system. This project leverages the robust capabilities of Vagrant VM, libvirt, and Docker Compose, which allows for seamless management of a Windows instance just like any Docker container. The added value here lies in the creation of a plug-and-play solution, significantly enhancing convenience, optimizing resource allocation, and integrating flawlessly with existing workflows. This strategy enriches CI/CD pipeline experiences in various dev-ops environments, providing a smooth and comprehensive approach that does not require prior knowledge of VM creation. +Self-hosted GitHub Actions runners on a Windows Server 2022 VM that you manage like any other Docker container on a Linux host. The container is [dockur/windows](https://github.com/dockur/windows): it installs Windows from the Microsoft evaluation ISO on first start, then this repo's `oem/` scripts install the build toolchain and register the runners. Later starts just boot the installed VM. No custom image, no Vagrant, no libvirt, no privileged container. ⭐ **Don't forget to star the project if it helped you!** +> **Version 2 replaces the Vagrant/libvirt image.** Releases up to 1.3.0 shipped a container image that ran the VM through Vagrant and libvirt and re-provisioned it on every start. That approach is deprecated and no longer maintained. See [Migrating from 1.x](#-migrating-from-1x). + # 📋 Prerequisites -- **Linux host** with hardware virtualization enabled and `/dev/kvm` present. - Check with `lscpu | grep -i Virtualization` (`VT-x` or `AMD-V`). Without KVM the VM falls back to plain QEMU and is several times slower. +- **Linux host** with hardware virtualization enabled: `/dev/kvm` and `/dev/net/tun` present. Check with `lscpu | grep -i Virtualization` (`VT-x` or `AMD-V`). - [Docker](https://www.docker.com/) 20 or higher with Compose v2 (`docker compose`). -- `privileged: true` and `cgroup: host` in the compose file — libvirt needs both. +- Disk: `DISK_SIZE` (default 100 GB) plus about 10 GB for the install ISO under `storage/`. # 🚥 Authentication for Self-Hosted Runners Two authentication methods are supported. Set **one** of them in `.env`: -1. Personal Access Token (`PAT`) — a long-lived token you create on GitHub. It needs the *Self-hosted runners: Read and write* permission for organization runners (or admin access to the repository for repository runners). The runner only uses it to fetch a registration token; it is not stored in the VM. -2. Registration Token (`TOKEN`) — the short-lived token GitHub shows under *Settings → Actions → Runners → New self-hosted runner*. It expires after one hour, so it must be fresh when the container starts. +1. Personal Access Token (`PAT`) — a long-lived token you create on GitHub. It needs the *Self-hosted runners: Read and write* permission for organization runners (or admin access to the repository for repository runners). The runner only uses it to fetch a registration token. +2. Registration Token (`TOKEN`) — the short-lived token GitHub shows under *Settings → Actions → Runners → New self-hosted runner*. It expires after one hour, so it must be fresh when the first start reaches the runner step (40 minutes or more in). Prefer `PAT`. # 🚀 Deployment Guide -1. Create `.env` from the template and fill it in: +1. Clone the repository and create `.env` from the template: ```bash + git clone https://github.com/vaggeliskls/windows-github-custom-runner.git + cd windows-github-custom-runner cp .env.example .env ``` - | Variable | Required | Description | + | Variable | Default | Description | |---|---|---| - | `RUNNER_URL` | yes | Org (`https://github.com/`) or repo (`https://github.com//`) the runners register with | - | `PAT` / `TOKEN` | one of | See [Authentication](#-authentication-for-self-hosted-runners) | - | `RUNNERS` | no (`1`) | Number of runners started in the VM | - | `MEMORY` | no (`8000`) | VM memory in MiB | - | `CPU` | no (`4`) | VM vCPUs | - | `DISK_SIZE` | no (`100`) | VM disk in GiB | - | `GITHUB_RUNNER_VERSION` | no | Override the [actions/runner](https://github.com/actions/runner/releases) version baked into the image — no rebuild needed | - | `GITHUB_RUNNER_NAME` | no | Runner name prefix (default `windows_x64_vagrant`) | - | `GITHUB_RUNNER_LABELS` | no | Comma-separated labels (default `windows,win_x64,windows_x64,windows_vagrant_action`) | - -2. Create `docker-compose.yml`: - - ```yaml - services: - windows-github-runner-vm: - image: docker.io/vaggeliskls/windows-github-custom-runner:latest - platform: linux/amd64 - hostname: windows-github-runner - env_file: .env - stdin_open: true - tty: true - privileged: true - cgroup: host - restart: unless-stopped - ports: - - 3389:3389 # RDP - - 2222:2222 # SSH + | `RUNNER_URL` | required | Org (`https://github.com/`) or repo (`https://github.com//`) the runners register with | + | `PAT` / `TOKEN` | one required | See [Authentication](#-authentication-for-self-hosted-runners) | + | `RUNNERS` | `1` | Number of runners started in the VM | + | `GITHUB_RUNNER_VERSION` | latest | [actions/runner](https://github.com/actions/runner/releases) release, without the `v` | + | `GITHUB_RUNNER_NAME` | `windows_x64_dockur` | Runner name prefix | + | `GITHUB_RUNNER_LABELS` | `windows,win_x64,windows_x64,windows_dockur_action` | Comma-separated labels | + | `GITHUB_RUNNER_GROUP` | | Runner group (organization runners only) | + | `RUNNER_HOST_ID` | Windows computer name | Middle part of the runner name, see [Runner names](#-runner-names-and-modes) | + | `RUNNER_MODE` | `service` | `service` or `interactive`, see [Runner names](#-runner-names-and-modes) | + | `RUNNER_SERVICE_ACCOUNT` | the Windows user | Service logon account. Built-in accounts such as `NT AUTHORITY\NETWORK SERVICE` need no password | + | `INSTALL_VISUAL_STUDIO` | `true` | Install Visual Studio 2022 | + | `VS_EDITION` | `enterprise` | `enterprise`, `professional`, `community` or `buildtools` | + | `VS_WORKLOADS` | `Microsoft.VisualStudio.Workload.NativeDesktop` | Comma-separated workload ids; `buildtools` wants `Microsoft.VisualStudio.Workload.VCTools` | + | `INSTALL_RTOOLS` | `true` | Install Rtools 4.0 with `mingw-w64-x86_64-make` | + | `VERSION` | `2022` | dockur Windows release (`2022` = Windows Server 2022 evaluation) | + | `RAM_SIZE` / `CPU_CORES` / `DISK_SIZE` | `8G` / `4` / `100G` | VM size | + | `WIN_USERNAME` / `WIN_PASSWORD` | `Docker` / `admin` | Local administrator created by the install, also the RDP login | + +2. Start it and follow the container side (ISO download, QEMU boot): + + ```bash + docker compose up -d + docker compose logs -f ``` -3. Run `docker compose up -d` and follow progress with `docker compose logs -f`. +3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. Its log is mirrored to the host: + + ```bash + tail -f shared/install.log + ``` + + `shared/install.done` or `shared/install.failed` appears when it finishes. + +> **First start takes 40 to 70 minutes**: ISO download (about 5 GB), Windows setup, Visual Studio, Rtools, runner registration. The runners register at the very end. Later starts take a minute or two and need no provisioning. + +The [docker-compose.yml](docker-compose.yml) is self-contained. If you do not want to clone the repo, copy it together with `oem/` and `.env.example`. + +## How configuration reaches the VM + +`.env` is used twice. Compose reads it for the `${...}` values in [docker-compose.yml](docker-compose.yml) (VM size, Windows user). It is also bind-mounted to `/shared/runner.env`, which dockur exports to the VM as `\\host.lan\Data\runner.env` (drive `Z:`). [install.ps1](oem/install.ps1) reads `RUNNER_URL`, `PAT`, `RUNNERS` and the rest from there. -> **First boot takes a long time.** The Windows box is baked into the image, but the VM still has to boot and run the full provisioning (Chocolatey, Visual Studio, Rtools, runner download). Expect 20–40 minutes depending on hardware and bandwidth; the runners register at the very end, once the toolchain is installed. +> **Remove the mount after the first install.** The shared folder is readable by every process in the VM, including the CI jobs your runners execute, so a workflow could read the `PAT` and the Windows password from `Z:\runner.env`. Once `shared/install.done` appears, comment out the `./.env:/shared/runner.env` line in [docker-compose.yml](docker-compose.yml) and run `docker compose up -d`. Put it back only when you re-run `install.ps1`. A one-hour `TOKEN` limits the exposure further. -## Runner names +The `oem/` folder itself holds no secrets on purpose: dockur bakes it into the install ISO that stays in `storage/`, and a PAT should not be kept there. `.env`, `storage/` and `shared/` are git-ignored. -Runners are registered as `__`, e.g. `windows_x64_vagrant_windows-github-runner_1`. The names are deliberately stable: when the container restarts, `config.cmd --replace` takes over the existing registrations instead of leaving offline duplicates behind in GitHub. +# 🏷 Runner names and modes -If you run more than one container against the same org or repo, give each one a different `hostname:` in its compose file. +Runners register as `__`, e.g. `windows_x64_dockur_WIN-ABC123_1`. dockur gives the VM a random computer name at install time, so set `RUNNER_HOST_ID` if you run several VMs against the same org or want names that survive a wipe of `storage/`. Registration uses `--replace`, so re-running takes over an existing registration instead of leaving offline duplicates in GitHub. + +- **service** (default): each runner is a Windows service that runs as the Windows user and starts with the VM. No desktop session, which is fine for command-line builds. +- **interactive**: each runner is a scheduled task in the auto-logon desktop session, restarted at every logon. Use it for jobs that drive GUI tooling. This matches what the 1.x image did. # 🧰 What's in the VM -Provisioning installs, on top of the base [Windows Server 2022 box](https://github.com/vaggeliskls/windows-in-docker-container): +On top of Windows Server 2022, [install.ps1](oem/install.ps1) installs: - PowerShell 7, Chocolatey, 7-Zip, Git, jq -- Visual Studio 2022 Enterprise with the *Desktop development with C++* workload (`--includeRecommended`) +- Visual Studio 2022 Enterprise with the *Desktop development with C++* workload (`--includeRecommended`), configurable through `VS_EDITION` and `VS_WORKLOADS` - Rtools 4.0 with `mingw-w64-x86_64-make` -- Long paths enabled, firewall disabled, `C:` grown to the full virtual disk +- Long paths enabled system-wide and in Git; `C:` grown to the full virtual disk -> **Licensing:** Visual Studio Enterprise requires a matching subscription for everyone whose builds use this runner. If that does not fit, fork the repo and switch [Vagrantfile](Vagrantfile) to `vs_buildtools.exe` with the `Microsoft.VisualStudio.Workload.VCTools` workload. +> **Licensing:** Visual Studio Enterprise requires a matching subscription for everyone whose builds use this runner. Set `VS_EDITION=buildtools` and `VS_WORKLOADS=Microsoft.VisualStudio.Workload.VCTools` for a C++ toolchain without that requirement. Windows itself is the 180-day evaluation that dockur installs. -# 🌐 Access +# 🔧 Changing settings later -For debugging or testing you can connect to the VM directly. +Edit `.env` on the host, then connect by RDP and run in an elevated PowerShell: -### Remote Desktop (RDP) — port `3389` +```powershell +powershell -ExecutionPolicy Bypass -File C:\OEM\install.ps1 -RunnersOnly +``` -| OS | Software | -|---|---| -| **Linux** | [`rdesktop`](https://github.com/rdesktop/rdesktop) → `rdesktop :3389`, or [Remmina](https://remmina.org/) | -| **macOS** | [Windows App](https://apps.apple.com/us/app/windows-app/id1295203466?mt=12) (formerly Microsoft Remote Desktop) | -| **Windows** | Built-in **Remote Desktop Connection** | +That removes the old registrations and re-registers with the new count, labels, version or mode. Without `-RunnersOnly` the toolchain steps run again too. To start over completely: `docker compose down`, delete `storage/`, `docker compose up -d`. -### SSH — port `2222` +`RAM_SIZE`, `CPU_CORES` and `DISK_SIZE` apply on the next `docker compose up`. A larger disk grows `C:` the next time `install.ps1` runs. -```bash -ssh vagrant@ -p 2222 -``` +# 🌐 Access + +| | | +|---|---| +| Web console | `http://:8006` | +| RDP | `:3389` — Linux: [`rdesktop`](https://github.com/rdesktop/rdesktop) or [Remmina](https://remmina.org/); macOS: [Windows App](https://apps.apple.com/us/app/windows-app/id1295203466?mt=12); Windows: Remote Desktop Connection | +| Shared folder | `./shared` on the host is `Z:` in the VM | # 🔑 User Login -The default users from the Vagrant box are: +The unattended install creates one local administrator, `WIN_USERNAME` / `WIN_PASSWORD` from `.env` (default `Docker` / `admin`). Change the password before exposing RDP beyond your network. + +# 🔁 Migrating from 1.x + +| | 1.x (Vagrant + libvirt image) | 2.x (dockur/windows) | +|---|---|---| +| Deployment | `image: vaggeliskls/windows-github-custom-runner` | `dockurr/windows` plus this repo's `oem/` folder, see [docker-compose.yml](docker-compose.yml) | +| Host flags | `privileged: true`, `cgroup: host` | `devices: /dev/kvm, /dev/net/tun`, `cap_add: NET_ADMIN` | +| VM sizing | `MEMORY=8000`, `CPU=4`, `DISK_SIZE=100` | `RAM_SIZE=8G`, `CPU_CORES=4`, `DISK_SIZE=100G` | +| Provisioning | every container start, 20 to 40 min | once, persisted in `storage/` | +| Runner process | `run.cmd` in an interactive session | Windows service (`RUNNER_MODE=interactive` restores the old behaviour) | +| Runner name | `__` | `__` | +| Default labels | `...,windows_vagrant_action` | `...,windows_dockur_action`. Workflows that use `runs-on: windows_vagrant_action` should switch to `windows_x64`, or add the old label to `GITHUB_RUNNER_LABELS` | +| Windows login | `vagrant` / `vagrant` | `Docker` / `admin` | +| SSH on 2222 | yes | no; use RDP or the web console | -1. Administrator - - Username: `Administrator` - - Password: `vagrant` -2. User - - Username: `vagrant` - - Password: `vagrant` +Remove the old runners from GitHub (*Settings → Actions → Runners*) after the new ones are online; their names differ, so `--replace` does not take them over. # 🛠 Development -- The image only adds a [Vagrantfile](Vagrantfile) on top of [windows-in-docker-container](https://github.com/vaggeliskls/windows-in-docker-container); the container startup script comes from the base image. -- `docker compose build` builds the image locally (the base image is large — it contains the Windows box). -- Pull requests run [lint.yml](.github/workflows/lint.yml): `hadolint` on the Dockerfile, `ruby -c` on the Vagrantfile, `actionlint` on the workflows. Publishing a GitHub release builds and pushes the image to Docker Hub and GHCR via [ci.yml](.github/workflows/ci.yml). -- Dependabot keeps the GitHub Actions and the base image tag current. The runner version is set by `GITHUB_RUNNER_VERSION` in the [Dockerfile](Dockerfile). +- Everything lives in [oem/install.bat](oem/install.bat), [oem/install.ps1](oem/install.ps1) and [docker-compose.yml](docker-compose.yml). There is no image to build. +- To test script changes, edit `oem/`, delete `storage/` and run `docker compose up -d` again. For runner-only changes, re-run `install.ps1 -RunnersOnly` inside the VM. +- Pull requests run [lint.yml](.github/workflows/lint.yml): `docker compose config`, a PowerShell parse plus [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) on `install.ps1`, and actionlint on the workflows. +- Dependabot keeps the GitHub Actions and the `dockurr/windows` tag current. # 📚 Further Reading and Resources -- [Windows in docker container](https://github.com/vaggeliskls/windows-in-docker-container) (base image) +- [dockur/windows](https://github.com/dockur/windows) — Windows in a Docker container - [GitHub: self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners) -- [Windows Vagrant Tutorial](https://github.com/SecurityWeekly/vulhub-lab) -- [Vagrant box: peru/windows-server-2022-standard-x64-eval](https://portal.cloud.hashicorp.com/vagrant/discover/peru/windows-server-2022-standard-x64-eval) -- [Vagrant by HashiCorp](https://www.vagrantup.com/) -- [Windows Virtual Machine in a Linux Docker Container](https://medium.com/axon-technologies/installing-a-windows-virtual-machine-in-a-linux-docker-container-c78e4c3f9ba1) +- [actions/runner releases](https://github.com/actions/runner/releases) +- [Visual Studio workload and component IDs](https://learn.microsoft.com/en-us/visualstudio/install/workload-component-id-vs-enterprise) diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index b23d8f6..0000000 --- a/Vagrantfile +++ /dev/null @@ -1,88 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : -# -# Everything is driven by environment variables: the Dockerfile sets the -# GITHUB_RUNNER_* defaults and .env supplies the rest (see README). -require "socket" - -cpu_count = (ENV["CPU"] || 4).to_i -memory_size = (ENV["MEMORY"] || 8000).to_i -disk_size = (ENV["DISK_SIZE"] || 100).to_i -privileged = (ENV["PRIVILEGED"] || "true") == "true" -interactive = (ENV["INTERACTIVE"] || "true") == "true" - -runners = (ENV["RUNNERS"] || 1).to_i -runner_url = ENV["RUNNER_URL"].to_s -runner_version = ENV["GITHUB_RUNNER_VERSION"].to_s -runner_name = ENV["GITHUB_RUNNER_NAME"] || "windows_x64_vagrant" -runner_labels = ENV["GITHUB_RUNNER_LABELS"] || "windows,win_x64,windows_x64,windows_vagrant_action" -runner_file = "actions-runner-win-x64-#{runner_version}.zip" -runner_pkg_url = ENV["GITHUB_RUNNER_URL"] || "https://github.com/actions/runner/releases/download/v#{runner_version}/#{runner_file}" -pat = ENV["PAT"].to_s -token = ENV["TOKEN"].to_s - -# Runner names must be stable across container restarts so `config.cmd --replace` -# reuses the existing registration instead of leaving offline duplicates in GitHub. -# The container hostname is the stable part; set `hostname:` in docker-compose.yml. -host_id = ENV["HOSTNAME"] || Socket.gethostname - -abort "RUNNER_URL must be set to an org (https://github.com/) or repo (https://github.com//) URL" if runner_url.empty? -abort "GITHUB_RUNNER_VERSION must be set" if runner_version.empty? -abort "Set PAT or TOKEN so the runners can register" if pat.empty? && token.empty? -auth_arg = pat.empty? ? "--token #{token}" : "--pat #{pat}" - -Vagrant.configure("2") do |config| - config.vm.box = ENV["VAGRANT_BOX"] || "peru/windows-server-2022-standard-x64-eval" - config.vm.box_check_update = false - config.vm.network "forwarded_port", guest: 22, host: 2222, id: "ssh" - config.vm.network "forwarded_port", guest: 3389, host: 3389, id: "rdp" - - config.vm.provider "libvirt" do |libvirt| - libvirt.driver = ENV["LIBVIRT_DRIVER"] || "kvm" - libvirt.memory = memory_size - libvirt.cpus = cpu_count - libvirt.machine_virtual_size = disk_size - libvirt.forward_ssh_port = true - end - config.winrm.max_tries = 300 # default is 20 - config.winrm.retry_delay = 5 # seconds (default) - - config.vm.provision "shell", inline: "Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled False" - config.vm.provision "shell", powershell_elevated_interactive: interactive, privileged: privileged, inline: <<~SHELL - # PowerShell 7 and Chocolatey (also pulls in 7-Zip) - Invoke-Expression "& { $(Invoke-RestMethod 'https://aka.ms/install-powershell.ps1') } -AddToPath" - Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) - choco install 7zip.install git.install jq -y - # Work from C:\\ - Set-Location / - # Visual Studio 2022 Enterprise, native desktop (C++) workload. - # The bootstrapper returns immediately unless told to wait, which would let - # runners register and pick up jobs before the toolchain exists. - Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vs_enterprise.exe" -OutFile "vs_enterprise.exe" - Start-Process -FilePath ".\\vs_enterprise.exe" -ArgumentList "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.NativeDesktop --includeRecommended" -Wait - Remove-Item -Path ./vs_enterprise.exe - # Rtools 4.0 + mingw-w64 make - Invoke-WebRequest -Uri "https://cran.r-project.org/bin/windows/Rtools/rtools40-x86_64.exe" -OutFile "rtools.exe" - Start-Process "./rtools.exe" -ArgumentList "/Silent" -PassThru -Wait - [Environment]::SetEnvironmentVariable("PATH", $env:Path + ";C:\\rtools40\\usr\\bin;C:\\rtools40\\mingw64\\bin", [EnvironmentVariableTarget]::Machine) - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - C:\\rtools40\\msys2.exe pacman -Sy --noconfirm mingw-w64-x86_64-make - Remove-Item -Path ./rtools.exe - # Grow C: to the full virtual disk - Resize-Partition -DriveLetter "C" -Size (Get-PartitionSupportedSize -DriveLetter "C").SizeMax - # Enable long paths - New-ItemProperty -Path "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force - # GitHub Actions runners: one directory each, run as the vagrant user - $credentials = New-Object System.Management.Automation.PSCredential -ArgumentList @("VAGRANTVM\\vagrant", (ConvertTo-SecureString -String "vagrant" -AsPlainText -Force)) - Invoke-WebRequest -Uri "#{runner_pkg_url}" -OutFile "#{runner_file}" - Remove-Item -Path C:\\runner-* -Recurse -Force -ErrorAction SilentlyContinue - for ($i = 1; $i -le #{runners}; $i++) { - $dir = "C:\\runner-$i" - Write-Host "Configuring runner $i of #{runners} in $dir" - Expand-Archive -LiteralPath "#{runner_file}" -DestinationPath $dir -Force - & "$dir\\config.cmd" --unattended --replace --name "#{runner_name}_#{host_id}_$i" --url "#{runner_url}" --labels "#{runner_labels}" #{auth_arg} - Start-Process -FilePath "$dir\\run.cmd" -Credential $credentials - } - Remove-Item -Path "#{runner_file}" - SHELL -end diff --git a/docker-compose.yml b/docker-compose.yml index 550c30b..d104cb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,38 @@ +# GitHub Actions runners on a Windows Server 2022 VM, built on dockur/windows. +# dockur boots QEMU directly and installs Windows from the Microsoft evaluation +# ISO on first start; oem/install.bat then installs the toolchain and registers +# the runners. Later starts boot the installed VM, no provisioning. +# +# Settings live in .env (copy .env.example). Compose reads it for the ${...} +# values below, and the same file is bind-mounted into the VM's shared folder +# so install.ps1 can read RUNNER_URL, PAT, RUNNERS, ... from there. Nothing from +# .env goes into /oem: dockur bakes that folder into the install ISO it keeps +# under ./storage, and a PAT should not end up in there. services: - windows-github-runner-vm: - image: docker.io/vaggeliskls/windows-github-custom-runner:latest - build: - context: . - dockerfile: Dockerfile - platform: linux/amd64 - # Part of every runner's name; give each container its own hostname - hostname: windows-github-runner - env_file: .env - stdin_open: true - tty: true - privileged: true - cgroup: host - restart: unless-stopped - # runtime: nvidia + windows-github-runner: + image: dockurr/windows:6.06 + environment: + VERSION: "${VERSION:-2022}" + RAM_SIZE: "${RAM_SIZE:-8G}" + CPU_CORES: "${CPU_CORES:-4}" + DISK_SIZE: "${DISK_SIZE:-100G}" + USERNAME: "${WIN_USERNAME:-Docker}" + PASSWORD: "${WIN_PASSWORD:-admin}" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN ports: - - 3389:3389 # RDP - - 2222:2222 # SSH + - 8006:8006 # web console (noVNC), handy during the first install + - 3389:3389/tcp # RDP + - 3389:3389/udp + volumes: + - ./oem:/oem:ro # install.bat + install.ps1, copied to C:\OEM at install time + - ./storage:/storage # Windows disk + install ISO, persists across restarts + - ./shared:/shared # \\host.lan\Data (Z:) in the VM; install.log lands here + # read by install.ps1. Every process in the VM, CI jobs included, can read + # this share: comment the line out once the runners are registered. + - ./.env:/shared/runner.env:ro + restart: unless-stopped + stop_grace_period: 2m diff --git a/oem/install.bat b/oem/install.bat new file mode 100644 index 0000000..b2ec717 --- /dev/null +++ b/oem/install.bat @@ -0,0 +1,8 @@ +@echo off +rem dockur/windows copies this folder to C:\OEM and runs install.bat once, at the +rem end of the unattended installation, as the auto-logon administrator. +rem Everything happens in install.ps1. Its log is C:\OEM\install.log, copied to +rem the shared folder (./shared on the host) when it finishes. The redirect +rem below only catches PowerShell failing to start at all. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" >> "%~dp0install.bat.log" 2>&1 +exit /b %ERRORLEVEL% diff --git a/oem/install.ps1 b/oem/install.ps1 new file mode 100644 index 0000000..9282bfc --- /dev/null +++ b/oem/install.ps1 @@ -0,0 +1,309 @@ +<# +.SYNOPSIS + Turns a dockur/windows guest into one or more GitHub Actions runners. + +.DESCRIPTION + dockur/windows copies the /oem folder to C:\OEM and runs C:\OEM\install.bat + once, at the end of the unattended installation, as the auto-logon + administrator (its answer file disables UAC and the firewall). install.bat + hands over to this script, which installs the build toolchain and registers + the runners. + + Settings come from runner.env, a KEY=VALUE file with the layout of the + repo's .env. docker-compose.yml bind-mounts .env to /shared/runner.env, + which the guest sees as \\host.lan\Data\runner.env (drive Z:). Nothing + secret is placed in /oem because dockur bakes that folder into the install + ISO it keeps in /storage. + + Progress is written to C:\OEM\install.log and copied to the shared folder + together with an install.done or install.failed marker. + +.EXAMPLE + # Re-run from an RDP session after changing runner.env (RUNNERS, labels, + # version, ...). Skips the toolchain and re-registers the runners. + powershell -ExecutionPolicy Bypass -File C:\OEM\install.ps1 -RunnersOnly +#> +[CmdletBinding()] +param( + # Path to runner.env. Default: the shared folder, then C:\OEM\runner.env. + [string]$ConfigPath, + + # Skip Chocolatey, Visual Studio and Rtools; only (re)configure the runners. + [switch]$RunnersOnly +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest is much faster without the progress bar +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + +$Share = '\\host.lan\Data' +$LogFile = 'C:\OEM\install.log' +$Temp = Join-Path $env:TEMP 'runner-install' +$RebootRequired = $false + +function Write-Log([string]$Message) { + Write-Host ('[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message) +} + +function Update-ProcessPath { + $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') +} + +function Add-MachinePath([string[]]$Dirs) { + $current = [Environment]::GetEnvironmentVariable('Path', 'Machine').Split(';') | Where-Object { $_ } + $new = @($current) + @($Dirs | Where-Object { $current -notcontains $_ }) + [Environment]::SetEnvironmentVariable('Path', ($new -join ';'), 'Machine') + Update-ProcessPath +} + +# KEY=VALUE lines; blank lines and # comments ignored; surrounding quotes stripped. +function Read-EnvFile([string]$Path) { + $cfg = @{} + foreach ($raw in Get-Content -LiteralPath $Path) { + $line = $raw.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $idx = $line.IndexOf('=') + if ($idx -lt 1) { continue } + $key = $line.Substring(0, $idx).Trim() + $val = $line.Substring($idx + 1).Trim() + if ($val.Length -ge 2 -and (($val[0] -eq '"' -and $val[-1] -eq '"') -or ($val[0] -eq "'" -and $val[-1] -eq "'"))) { + $val = $val.Substring(1, $val.Length - 2) + } + $cfg[$key] = $val + } + return $cfg +} + +function Find-Config { + if ($ConfigPath) { return $ConfigPath } + $candidates = @("$Share\runner.env", 'Z:\runner.env', 'C:\OEM\runner.env') + # The samba share can take a moment to become reachable after first logon. + $deadline = (Get-Date).AddMinutes(5) + do { + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + Start-Sleep -Seconds 10 + } while ((Get-Date) -lt $deadline) + throw "runner.env not found (looked in $($candidates -join ', ')). Check the volumes in docker-compose.yml." +} + +function Get-Setting($Config, [string]$Key, $Default = '') { + if ($Config.ContainsKey($Key) -and $Config[$Key] -ne '') { return $Config[$Key] } + return $Default +} + +function Test-Enabled($Config, [string]$Key, [bool]$Default = $true) { + $value = (Get-Setting $Config $Key ($Default.ToString())).ToLower() + return $value -in 'true', '1', 'yes', 'y' +} + +function Invoke-Download([string]$Uri, [string]$OutFile) { + Write-Log "Downloading $Uri" + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing +} + +# --- Toolchain --------------------------------------------------------------- + +function Initialize-System { + Write-Log 'Enabling long paths' + New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 -PropertyType DWORD -Force | Out-Null + + # Grow C: when DISK_SIZE is larger than the partition dockur created + try { + $max = (Get-PartitionSupportedSize -DriveLetter C).SizeMax + $cur = (Get-Partition -DriveLetter C).Size + if (($max - $cur) -gt 1GB) { + Write-Log 'Growing C: to the full virtual disk' + Resize-Partition -DriveLetter C -Size $max + } + } catch { + Write-Log "Could not resize C: ($_)" + } +} + +function Install-BaseTools { + Write-Log 'Installing PowerShell 7' + Invoke-Expression "& { $(Invoke-RestMethod 'https://aka.ms/install-powershell.ps1') } -UseMSI -Quiet" + + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Log 'Installing Chocolatey' + Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + Update-ProcessPath + } + + Write-Log 'Installing Git, jq, 7-Zip' + choco install -y --no-progress git.install jq 7zip.install + if ($LASTEXITCODE -notin 0, 1641, 3010) { throw "choco install failed with exit code $LASTEXITCODE" } + Update-ProcessPath + git config --system core.longpaths true +} + +function Install-VisualStudio([string]$Edition, [string]$Workloads) { + $exe = Join-Path $Temp "vs_$Edition.exe" + Invoke-Download "https://aka.ms/vs/17/release/vs_$Edition.exe" $exe + + $vsArgs = @('--quiet', '--wait', '--norestart', '--includeRecommended') + foreach ($workload in ($Workloads -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { + $vsArgs += @('--add', $workload) + } + + Write-Log "Installing Visual Studio 2022 $Edition with $Workloads (this takes a while)" + $proc = Start-Process -FilePath $exe -ArgumentList $vsArgs -Wait -PassThru + switch ($proc.ExitCode) { + 0 { Write-Log 'Visual Studio installed' } + 3010 { Write-Log 'Visual Studio installed, reboot required'; $script:RebootRequired = $true } + default { throw "Visual Studio installer failed with exit code $($proc.ExitCode)" } + } +} + +function Install-Rtools { + $exe = Join-Path $Temp 'rtools40.exe' + Invoke-Download 'https://cran.r-project.org/bin/windows/Rtools/rtools40-x86_64.exe' $exe + + Write-Log 'Installing Rtools 4.0' + $proc = Start-Process -FilePath $exe -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru + if ($proc.ExitCode -ne 0) { throw "Rtools installer failed with exit code $($proc.ExitCode)" } + Add-MachinePath 'C:\rtools40\usr\bin', 'C:\rtools40\mingw64\bin' + + Write-Log 'Installing mingw-w64-x86_64-make' + & 'C:\rtools40\usr\bin\pacman.exe' -Sy --noconfirm mingw-w64-x86_64-make + if ($LASTEXITCODE -ne 0) { throw "pacman failed with exit code $LASTEXITCODE" } +} + +# --- Runners ----------------------------------------------------------------- + +function Resolve-RunnerVersion([string]$Version) { + if ($Version) { return $Version.TrimStart('v') } + Write-Log 'GITHUB_RUNNER_VERSION not set, using the latest actions/runner release' + $release = Invoke-RestMethod 'https://api.github.com/repos/actions/runner/releases/latest' -Headers @{ 'User-Agent' = 'windows-github-custom-runner' } + return $release.tag_name.TrimStart('v') +} + +# Interactive mode: run.cmd as a scheduled task in the auto-logon desktop session, +# restarted at every logon, so jobs can drive GUI tooling. +function Register-InteractiveRunner([int]$Index, [string]$Dir) { + $user = "$env:USERDOMAIN\$env:USERNAME" + $taskName = "GitHub runner $Index" + $action = New-ScheduledTaskAction -Execute "$Dir\run.cmd" -WorkingDirectory $Dir + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user + $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $settings.ExecutionTimeLimit = 'PT0S' # no time limit + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null + Start-ScheduledTask -TaskName $taskName +} + +function Install-Runners($Config) { + $url = Get-Setting $Config 'RUNNER_URL' + $pat = Get-Setting $Config 'PAT' + $token = Get-Setting $Config 'TOKEN' + $count = [int](Get-Setting $Config 'RUNNERS' 1) + $prefix = Get-Setting $Config 'GITHUB_RUNNER_NAME' 'windows_x64_dockur' + $labels = Get-Setting $Config 'GITHUB_RUNNER_LABELS' 'windows,win_x64,windows_x64,windows_dockur_action' + $group = Get-Setting $Config 'GITHUB_RUNNER_GROUP' + $hostId = Get-Setting $Config 'RUNNER_HOST_ID' $env:COMPUTERNAME + $mode = (Get-Setting $Config 'RUNNER_MODE' 'service').ToLower() + $version = Resolve-RunnerVersion (Get-Setting $Config 'GITHUB_RUNNER_VERSION') + + if (-not $url) { throw 'RUNNER_URL must be set to https://github.com/ or https://github.com//' } + if (-not $pat -and -not $token) { throw 'Set PAT or TOKEN in runner.env so the runners can register' } + if ($mode -notin 'service', 'interactive') { throw "RUNNER_MODE must be 'service' or 'interactive', got '$mode'" } + $auth = if ($pat) { @('--pat', $pat) } else { @('--token', $token) } + + # Service account: the auto-logon user unless RUNNER_SERVICE_ACCOUNT says otherwise. + # config.cmd turns ".\user" into "\user" and grants it "Log on as a service". + $serviceAccount = Get-Setting $Config 'RUNNER_SERVICE_ACCOUNT' ".\$env:USERNAME" + $servicePassword = Get-Setting $Config 'WIN_PASSWORD' 'admin' + $builtinAccount = $serviceAccount -like 'NT AUTHORITY\*' -or $serviceAccount -eq 'LocalSystem' + + $zip = Join-Path $Temp "actions-runner-win-x64-$version.zip" + $pkgUrl = Get-Setting $Config 'GITHUB_RUNNER_URL' "https://github.com/actions/runner/releases/download/v$version/actions-runner-win-x64-$version.zip" + Invoke-Download $pkgUrl $zip + + $names = @() + for ($i = 1; $i -le $count; $i++) { + $dir = "C:\runner-$i" + $name = "${prefix}_${hostId}_$i" + $names += $name + Write-Log "Configuring runner $i of ${count}: $name in $dir" + + # A previous registration (re-run) is removed first so --replace has nothing stale to fight. + if (Test-Path -LiteralPath "$dir\.runner") { + Write-Log 'Removing the previous registration' + & "$dir\config.cmd" remove --unattended @auth | Out-Host + if ($LASTEXITCODE -ne 0) { Write-Log "config.cmd remove exited with $LASTEXITCODE, continuing" } + } + Get-ScheduledTask -TaskName "GitHub runner $i" -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$false + if (Test-Path -LiteralPath $dir) { Remove-Item -LiteralPath $dir -Recurse -Force } + Expand-Archive -LiteralPath $zip -DestinationPath $dir -Force + + $cfgArgs = @('--unattended', '--replace', '--name', $name, '--url', $url, '--labels', $labels) + $auth + if ($group) { $cfgArgs += @('--runnergroup', $group) } + if ($mode -eq 'service') { + $cfgArgs += @('--runasservice', '--windowslogonaccount', $serviceAccount) + if (-not $builtinAccount) { $cfgArgs += @('--windowslogonpassword', $servicePassword) } + } + & "$dir\config.cmd" @cfgArgs | Out-Host + if ($LASTEXITCODE -ne 0) { throw "config.cmd failed for $name with exit code $LASTEXITCODE" } + + if ($mode -eq 'interactive') { Register-InteractiveRunner $i $dir } + } + Remove-Item -LiteralPath $zip -Force + + return @{ Version = $version; Mode = $mode; Names = $names } +} + +# --- Main -------------------------------------------------------------------- + +$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this script from an elevated PowerShell' +} + +Start-Transcript -Path $LogFile -Append | Out-Null +New-Item -ItemType Directory -Path $Temp -Force | Out-Null +$status = 'failed' +try { + $configFile = Find-Config + Write-Log "Using settings from $configFile" + $config = Read-EnvFile $configFile + + if (-not $RunnersOnly) { + Initialize-System + Install-BaseTools + if (Test-Enabled $config 'INSTALL_VISUAL_STUDIO') { + Install-VisualStudio (Get-Setting $config 'VS_EDITION' 'enterprise') (Get-Setting $config 'VS_WORKLOADS' 'Microsoft.VisualStudio.Workload.NativeDesktop') + } + if (Test-Enabled $config 'INSTALL_RTOOLS') { Install-Rtools } + } + + $result = Install-Runners $config + + # Only the fallback location is on the Windows disk; do not leave a PAT there. + if ($configFile -ieq 'C:\OEM\runner.env') { Remove-Item -LiteralPath $configFile -Force } + + $status = 'done' + Write-Log "Registered $($result.Names.Count) runner(s), actions/runner $($result.Version), mode $($result.Mode): $($result.Names -join ', ')" + if ($RebootRequired) { + Write-Log 'Rebooting in 60 seconds to finish the Visual Studio installation' + shutdown.exe /r /t 60 /c 'Finishing GitHub runner setup' + } +} catch { + Write-Log "FAILED: $_" + Write-Log $_.ScriptStackTrace +} finally { + Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue + Stop-Transcript | Out-Null + + # Report back to the host through the shared folder (./shared) + try { + Copy-Item -LiteralPath $LogFile -Destination "$Share\install.log" -Force + Remove-Item -LiteralPath "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue + Set-Content -Path "$Share\install.$status" -Value (Get-Date -Format o) + } catch { + Write-Host "Could not write to $Share ($_)" + } +} + +if ($status -ne 'done') { exit 1 } From 542d3d555e6b79bbd17512d358cb6f13bd9becf7 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 01:27:03 +0300 Subject: [PATCH 02/13] chore: default Windows login to admin/runner --- .env.example | 4 ++-- README.md | 6 +++--- docker-compose.yml | 4 ++-- oem/install.ps1 | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 6a900b2..50e4249 100644 --- a/.env.example +++ b/.env.example @@ -55,5 +55,5 @@ RAM_SIZE=8G CPU_CORES=4 DISK_SIZE=100G # Local administrator created by the unattended install; also the RDP login -WIN_USERNAME=Docker -WIN_PASSWORD=admin +WIN_USERNAME=admin +WIN_PASSWORD=runner diff --git a/README.md b/README.md index 4f96e79..83ad338 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Two authentication methods are supported. Set **one** of them in `.env`: | `INSTALL_RTOOLS` | `true` | Install Rtools 4.0 with `mingw-w64-x86_64-make` | | `VERSION` | `2022` | dockur Windows release (`2022` = Windows Server 2022 evaluation) | | `RAM_SIZE` / `CPU_CORES` / `DISK_SIZE` | `8G` / `4` / `100G` | VM size | - | `WIN_USERNAME` / `WIN_PASSWORD` | `Docker` / `admin` | Local administrator created by the install, also the RDP login | + | `WIN_USERNAME` / `WIN_PASSWORD` | `admin` / `runner` | Local administrator created by the install, also the RDP login | 2. Start it and follow the container side (ISO download, QEMU boot): @@ -116,7 +116,7 @@ That removes the old registrations and re-registers with the new count, labels, # 🔑 User Login -The unattended install creates one local administrator, `WIN_USERNAME` / `WIN_PASSWORD` from `.env` (default `Docker` / `admin`). Change the password before exposing RDP beyond your network. +The unattended install creates one local administrator, `WIN_USERNAME` / `WIN_PASSWORD` from `.env` (default `admin` / `runner`). Change the password before exposing RDP beyond your network. # 🔁 Migrating from 1.x @@ -129,7 +129,7 @@ The unattended install creates one local administrator, `WIN_USERNAME` / `WIN_PA | Runner process | `run.cmd` in an interactive session | Windows service (`RUNNER_MODE=interactive` restores the old behaviour) | | Runner name | `__` | `__` | | Default labels | `...,windows_vagrant_action` | `...,windows_dockur_action`. Workflows that use `runs-on: windows_vagrant_action` should switch to `windows_x64`, or add the old label to `GITHUB_RUNNER_LABELS` | -| Windows login | `vagrant` / `vagrant` | `Docker` / `admin` | +| Windows login | `vagrant` / `vagrant` | `admin` / `runner` | | SSH on 2222 | yes | no; use RDP or the web console | Remove the old runners from GitHub (*Settings → Actions → Runners*) after the new ones are online; their names differ, so `--replace` does not take them over. diff --git a/docker-compose.yml b/docker-compose.yml index d104cb4..59f23d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,8 @@ services: RAM_SIZE: "${RAM_SIZE:-8G}" CPU_CORES: "${CPU_CORES:-4}" DISK_SIZE: "${DISK_SIZE:-100G}" - USERNAME: "${WIN_USERNAME:-Docker}" - PASSWORD: "${WIN_PASSWORD:-admin}" + USERNAME: "${WIN_USERNAME:-admin}" + PASSWORD: "${WIN_PASSWORD:-runner}" devices: - /dev/kvm - /dev/net/tun diff --git a/oem/install.ps1 b/oem/install.ps1 index 9282bfc..d2785ca 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -214,7 +214,7 @@ function Install-Runners($Config) { # Service account: the auto-logon user unless RUNNER_SERVICE_ACCOUNT says otherwise. # config.cmd turns ".\user" into "\user" and grants it "Log on as a service". $serviceAccount = Get-Setting $Config 'RUNNER_SERVICE_ACCOUNT' ".\$env:USERNAME" - $servicePassword = Get-Setting $Config 'WIN_PASSWORD' 'admin' + $servicePassword = Get-Setting $Config 'WIN_PASSWORD' 'runner' $builtinAccount = $serviceAccount -like 'NT AUTHORITY\*' -or $serviceAccount -eq 'LocalSystem' $zip = Join-Path $Temp "actions-runner-win-x64-$version.zip" From 21e639e477d8c35f1c86c2000a1a1c307ec56576 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 14:07:15 +0300 Subject: [PATCH 03/13] feat: self-healing install via logon task; clear error for missing .env install.ps1 - Register a "GitHub runner setup" scheduled task on the first run. It re-runs the script with -AtLogon at every logon: installs toolchain parts that are missing (checked per tool, not via a marker) and re-registers the runners when runner.env changed since they were last registered. Exits quietly otherwise, so fixing .env on the host and restarting the container is enough. - Find-Config rejects a directory named runner.env with an explanation: Docker creates one when .env is missing at 'docker compose up', and Get-Content on it only said "Access to the path is denied". - Initialize-System runs on every real run so a larger DISK_SIZE grows C:. .env.example: note that RAM/CPU apply at restart and DISK_SIZE can only grow. README: restart-based update flow, recovery from a missing .env. --- .env.example | 5 ++ README.md | 18 +++-- docker-compose.yml | 3 +- oem/install.ps1 | 160 ++++++++++++++++++++++++++++++++++++--------- 4 files changed, 150 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index 50e4249..145a7a5 100644 --- a/.env.example +++ b/.env.example @@ -49,8 +49,13 @@ RUNNERS=2 # INSTALL_RTOOLS=true # --- VM (dockur/windows) ---------------------------------------------------- +# All optional; these are the defaults from docker-compose.yml. # Windows release; 2022 = Windows Server 2022 evaluation VERSION=2022 +# RAM_SIZE and CPU_CORES apply at the next 'docker compose down && up'. +# DISK_SIZE can only grow; C: is extended by install.ps1 at the next boot. +# Never lower it (or drop it after raising it): dockur refuses to shrink the +# disk and the container will not start until the value is put back. RAM_SIZE=8G CPU_CORES=4 DISK_SIZE=100G diff --git a/README.md b/README.md index 83ad338..c2c39af 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ The [docker-compose.yml](docker-compose.yml) is self-contained. If you do not wa `.env` is used twice. Compose reads it for the `${...}` values in [docker-compose.yml](docker-compose.yml) (VM size, Windows user). It is also bind-mounted to `/shared/runner.env`, which dockur exports to the VM as `\\host.lan\Data\runner.env` (drive `Z:`). [install.ps1](oem/install.ps1) reads `RUNNER_URL`, `PAT`, `RUNNERS` and the rest from there. -> **Remove the mount after the first install.** The shared folder is readable by every process in the VM, including the CI jobs your runners execute, so a workflow could read the `PAT` and the Windows password from `Z:\runner.env`. Once `shared/install.done` appears, comment out the `./.env:/shared/runner.env` line in [docker-compose.yml](docker-compose.yml) and run `docker compose up -d`. Put it back only when you re-run `install.ps1`. A one-hour `TOKEN` limits the exposure further. +> **Create `.env` before the first `docker compose up`.** If it is missing, Docker creates `.env` as an empty *directory* in its place, the VM sees a folder named `runner.env`, and `install.log` ends with `runner.env is a directory, not a file`. To recover: `docker compose down`, `rmdir .env`, create `.env` from the template, `docker compose up -d`. The VM finishes the install at the next boot, see [Changing settings later](#-changing-settings-later). + +> **Remove the mount after the first install.** The shared folder is readable by every process in the VM, including the CI jobs your runners execute, so a workflow could read the `PAT` and the Windows password from `Z:\runner.env`. Once `shared/install.done` appears, comment out the `./.env:/shared/runner.env` line in [docker-compose.yml](docker-compose.yml) and run `docker compose up -d`. Put it back when you change `.env`; without it the logon check described below has nothing to apply. A one-hour `TOKEN` limits the exposure further. The `oem/` folder itself holds no secrets on purpose: dockur bakes it into the install ISO that stays in `storage/`, and a PAT should not be kept there. `.env`, `storage/` and `shared/` are git-ignored. @@ -96,15 +98,23 @@ On top of Windows Server 2022, [install.ps1](oem/install.ps1) installs: # 🔧 Changing settings later -Edit `.env` on the host, then connect by RDP and run in an elevated PowerShell: +Edit `.env` on the host and restart the container: + +```bash +docker compose down && docker compose up -d +``` + +The restart matters: editors replace the file on save, and a bind-mounted file keeps pointing at the old copy until the container is recreated. At every logon the VM runs `install.ps1 -AtLogon` from a scheduled task named *GitHub runner setup*. It checks that every toolchain part is present (PowerShell 7, Chocolatey, Git, jq, 7-Zip, and Visual Studio and Rtools when enabled) and installs only what is missing. It then compares `runner.env` with the copy the runners were last registered from and, when it changed, removes the old registrations and re-registers with the new count, labels, version or mode. That is also how a wrong `PAT` or `RUNNER_URL` on the first start is fixed: correct `.env`, restart, wait for `shared/install.done`. When everything is present and nothing changed, the task exits without touching `shared/`. The result of the check is in `shared/install.log` as `Toolchain: all present` or `Toolchain: installing ...`. + +The same can be done from an RDP session, in an elevated PowerShell and without a restart: ```powershell powershell -ExecutionPolicy Bypass -File C:\OEM\install.ps1 -RunnersOnly ``` -That removes the old registrations and re-registers with the new count, labels, version or mode. Without `-RunnersOnly` the toolchain steps run again too. To start over completely: `docker compose down`, delete `storage/`, `docker compose up -d`. +Without `-RunnersOnly` the toolchain steps run again too. To start over completely: `docker compose down`, delete `storage/`, `docker compose up -d`. -`RAM_SIZE`, `CPU_CORES` and `DISK_SIZE` apply on the next `docker compose up`. A larger disk grows `C:` the next time `install.ps1` runs. +`RAM_SIZE`, `CPU_CORES` and `DISK_SIZE` are passed to QEMU, so they apply at the next restart without any install step. `DISK_SIZE` can only grow; the logon task extends `C:` to the new size at that boot. # 🌐 Access diff --git a/docker-compose.yml b/docker-compose.yml index 59f23d0..b01aea6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,8 @@ # GitHub Actions runners on a Windows Server 2022 VM, built on dockur/windows. # dockur boots QEMU directly and installs Windows from the Microsoft evaluation # ISO on first start; oem/install.bat then installs the toolchain and registers -# the runners. Later starts boot the installed VM, no provisioning. +# the runners. Later starts boot the installed VM; a logon task in the VM +# re-applies runner.env only when it changed or an earlier run did not finish. # # Settings live in .env (copy .env.example). Compose reads it for the ${...} # values below, and the same file is bind-mounted into the VM's shared folder diff --git a/oem/install.ps1 b/oem/install.ps1 index d2785ca..2d46720 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -18,6 +18,12 @@ Progress is written to C:\OEM\install.log and copied to the shared folder together with an install.done or install.failed marker. + The first run registers a "GitHub runner setup" scheduled task that runs + this script with -AtLogon at every logon. That run repeats the install when + a previous run did not finish (wrong PAT, download failure, ...) or + re-registers the runners when runner.env changed, so fixing .env on the + host and restarting the container is enough. It does nothing otherwise. + .EXAMPLE # Re-run from an RDP session after changing runner.env (RUNNERS, labels, # version, ...). Skips the toolchain and re-registers the runners. @@ -29,7 +35,12 @@ param( [string]$ConfigPath, # Skip Chocolatey, Visual Studio and Rtools; only (re)configure the runners. - [switch]$RunnersOnly + [switch]$RunnersOnly, + + # Used by the "GitHub runner setup" scheduled task. Runs only when the + # toolchain install never finished or runner.env changed since the runners + # were last registered; exits quietly otherwise. + [switch]$AtLogon ) $ErrorActionPreference = 'Stop' @@ -38,6 +49,8 @@ $ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest is much faster with $Share = '\\host.lan\Data' $LogFile = 'C:\OEM\install.log' +$ConfigMarker = 'C:\OEM\runner.env.sha256' # hash of the runner.env the runners were last registered from +$SetupTask = 'GitHub runner setup' $Temp = Join-Path $env:TEMP 'runner-install' $RebootRequired = $false @@ -74,20 +87,43 @@ function Read-EnvFile([string]$Path) { return $cfg } -function Find-Config { +# Returns the path of runner.env, or $null with -Optional when there is none. +function Find-Config([switch]$Optional) { if ($ConfigPath) { return $ConfigPath } $candidates = @("$Share\runner.env", 'Z:\runner.env', 'C:\OEM\runner.env') - # The samba share can take a moment to become reachable after first logon. + # The samba share can take a moment to become reachable after logon. $deadline = (Get-Date).AddMinutes(5) do { foreach ($candidate in $candidates) { - if (Test-Path -LiteralPath $candidate) { return $candidate } + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + # Docker creates a missing bind-mount source as an empty directory, so a + # folder here means .env did not exist on the host when compose started. + if (Test-Path -LiteralPath $candidate -PathType Container) { + throw "$candidate is a directory, not a file. .env was missing on the host when 'docker compose up' ran: remove the .env directory, create .env from .env.example, then 'docker compose down' and 'docker compose up -d'." + } } Start-Sleep -Seconds 10 } while ((Get-Date) -lt $deadline) + if ($Optional) { return $null } throw "runner.env not found (looked in $($candidates -join ', ')). Check the volumes in docker-compose.yml." } +function Get-ConfigHash([string]$Path) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} + +# Re-runs this script with -AtLogon at every logon of the auto-logon user, so a +# corrected or changed runner.env is applied by restarting the container. +function Register-SetupTask { + $user = "$env:USERDOMAIN\$env:USERNAME" + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -ArgumentList "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSCommandPath`" -AtLogon" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user + $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $settings.ExecutionTimeLimit = 'PT0S' # a full install can take over an hour + Register-ScheduledTask -TaskName $SetupTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null +} + function Get-Setting($Config, [string]$Key, $Default = '') { if ($Config.ContainsKey($Key) -and $Config[$Key] -ne '') { return $Config[$Key] } return $Default @@ -122,21 +158,56 @@ function Initialize-System { } } -function Install-BaseTools { - Write-Log 'Installing PowerShell 7' - Invoke-Expression "& { $(Invoke-RestMethod 'https://aka.ms/install-powershell.ps1') } -UseMSI -Quiet" +function Test-VisualStudio { + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path -LiteralPath $vswhere)) { return $false } + return [bool](& $vswhere -products * -version '[17.0,18.0)' -property installationPath) +} + +# Names of the toolchain parts that are not installed yet. Decides whether a +# logon run has anything to do and which installers Install-Toolchain runs. +function Get-MissingTools($Config) { + Update-ProcessPath + $checks = [ordered]@{ + 'PowerShell 7' = { Test-Path -LiteralPath "$env:ProgramFiles\PowerShell\7\pwsh.exe" } + 'Chocolatey' = { [bool](Get-Command choco -ErrorAction SilentlyContinue) } + 'Git' = { [bool](Get-Command git -ErrorAction SilentlyContinue) } + 'jq' = { [bool](Get-Command jq -ErrorAction SilentlyContinue) } + '7-Zip' = { Test-Path -LiteralPath "$env:ProgramFiles\7-Zip\7z.exe" } + } + if (Test-Enabled $Config 'INSTALL_VISUAL_STUDIO') { $checks['Visual Studio'] = { Test-VisualStudio } } + if (Test-Enabled $Config 'INSTALL_RTOOLS') { + $checks['Rtools'] = { (Test-Path -LiteralPath 'C:\rtools40\usr\bin\pacman.exe') -and (Test-Path -LiteralPath 'C:\rtools40\mingw64\bin\mingw32-make.exe') } + } + return @($checks.Keys | Where-Object { -not (& $checks[$_]) }) +} - if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { +function Install-Toolchain($Config, [string[]]$Missing) { + if ($Missing -contains 'PowerShell 7') { + Write-Log 'Installing PowerShell 7' + Invoke-Expression "& { $(Invoke-RestMethod 'https://aka.ms/install-powershell.ps1') } -UseMSI -Quiet" + } + + if ($Missing -contains 'Chocolatey') { Write-Log 'Installing Chocolatey' Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) Update-ProcessPath } - Write-Log 'Installing Git, jq, 7-Zip' - choco install -y --no-progress git.install jq 7zip.install - if ($LASTEXITCODE -notin 0, 1641, 3010) { throw "choco install failed with exit code $LASTEXITCODE" } - Update-ProcessPath - git config --system core.longpaths true + $packages = @{ 'Git' = 'git.install'; 'jq' = 'jq'; '7-Zip' = '7zip.install' } + $wanted = @($packages.Keys | Where-Object { $Missing -contains $_ } | ForEach-Object { $packages[$_] }) + if ($wanted) { + Write-Log "Installing $($wanted -join ', ') with Chocolatey" + choco install -y --no-progress @wanted + if ($LASTEXITCODE -notin 0, 1641, 3010) { throw "choco install failed with exit code $LASTEXITCODE" } + Update-ProcessPath + git config --system core.longpaths true + } + + if ($Missing -contains 'Visual Studio') { + Install-VisualStudio (Get-Setting $Config 'VS_EDITION' 'enterprise') (Get-Setting $Config 'VS_WORKLOADS' 'Microsoft.VisualStudio.Workload.NativeDesktop') + } + if ($Missing -contains 'Rtools') { Install-Rtools } } function Install-VisualStudio([string]$Edition, [string]$Workloads) { @@ -265,26 +336,50 @@ Start-Transcript -Path $LogFile -Append | Out-Null New-Item -ItemType Directory -Path $Temp -Force | Out-Null $status = 'failed' try { - $configFile = Find-Config - Write-Log "Using settings from $configFile" + # Registered before anything that can fail, so a bad runner.env still gets + # a retry at the next boot. Not re-registered from the task itself. + if (-not $AtLogon) { Register-SetupTask } + + $configFile = Find-Config -Optional:$AtLogon + if (-not $configFile) { + Write-Log 'runner.env not found, nothing to do' + $status = 'skipped' + return + } $config = Read-EnvFile $configFile - if (-not $RunnersOnly) { - Initialize-System - Install-BaseTools - if (Test-Enabled $config 'INSTALL_VISUAL_STUDIO') { - Install-VisualStudio (Get-Setting $config 'VS_EDITION' 'enterprise') (Get-Setting $config 'VS_WORKLOADS' 'Microsoft.VisualStudio.Workload.NativeDesktop') + # What is left to do: toolchain parts that are not on the machine, and the + # runners when runner.env differs from the one they were registered from. + $missing = if ($RunnersOnly) { @() } else { @(Get-MissingTools $config) } + $registerRunners = $true + if ($AtLogon) { + $applied = if (Test-Path -LiteralPath $ConfigMarker) { Get-Content -LiteralPath $ConfigMarker } else { '' } + $registerRunners = (Get-ConfigHash $configFile) -ne $applied + if (-not $missing -and -not $registerRunners) { + $status = 'skipped' + return } - if (Test-Enabled $config 'INSTALL_RTOOLS') { Install-Rtools } } + Write-Log "Using settings from $configFile" - $result = Install-Runners $config + # Idempotent and quick; also grows C: after a DISK_SIZE change on the host. + Initialize-System + if ($RunnersOnly) { Write-Log 'Toolchain: skipped (-RunnersOnly)' } + elseif ($missing) { Write-Log "Toolchain: installing $($missing -join ', ')"; Install-Toolchain $config $missing } + else { Write-Log 'Toolchain: all present' } + + if ($registerRunners) { + $result = Install-Runners $config + Set-Content -LiteralPath $ConfigMarker -Value (Get-ConfigHash $configFile) + Write-Log "Registered $($result.Names.Count) runner(s), actions/runner $($result.Version), mode $($result.Mode): $($result.Names -join ', ')" + } else { + Write-Log 'Runners: runner.env unchanged, left as they are' + } # Only the fallback location is on the Windows disk; do not leave a PAT there. if ($configFile -ieq 'C:\OEM\runner.env') { Remove-Item -LiteralPath $configFile -Force } $status = 'done' - Write-Log "Registered $($result.Names.Count) runner(s), actions/runner $($result.Version), mode $($result.Mode): $($result.Names -join ', ')" if ($RebootRequired) { Write-Log 'Rebooting in 60 seconds to finish the Visual Studio installation' shutdown.exe /r /t 60 /c 'Finishing GitHub runner setup' @@ -296,14 +391,17 @@ try { Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue Stop-Transcript | Out-Null - # Report back to the host through the shared folder (./shared) - try { - Copy-Item -LiteralPath $LogFile -Destination "$Share\install.log" -Force - Remove-Item -LiteralPath "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue - Set-Content -Path "$Share\install.$status" -Value (Get-Date -Format o) - } catch { - Write-Host "Could not write to $Share ($_)" + # Report back to the host through the shared folder (./shared). A logon run + # that found nothing to do leaves the previous log and marker alone. + if ($status -ne 'skipped') { + try { + Copy-Item -LiteralPath $LogFile -Destination "$Share\install.log" -Force + Remove-Item -LiteralPath "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue + Set-Content -Path "$Share\install.$status" -Value (Get-Date -Format o) + } catch { + Write-Host "Could not write to $Share ($_)" + } } } -if ($status -ne 'done') { exit 1 } +if ($status -eq 'failed') { exit 1 } From 45812d5159731daf68e38644721caa2250394885 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 14:17:47 +0300 Subject: [PATCH 04/13] fix: New-ScheduledTaskAction takes -Argument, not -ArgumentList --- oem/install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oem/install.ps1 b/oem/install.ps1 index 2d46720..7f511a7 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -116,7 +116,7 @@ function Get-ConfigHash([string]$Path) { # corrected or changed runner.env is applied by restarting the container. function Register-SetupTask { $user = "$env:USERDOMAIN\$env:USERNAME" - $action = New-ScheduledTaskAction -Execute 'powershell.exe' -ArgumentList "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSCommandPath`" -AtLogon" + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSCommandPath`" -AtLogon" $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries From 1bb858cd5ace997a9aa17dcfdb722f4f5a7654d0 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 14:21:38 +0300 Subject: [PATCH 05/13] docs: how to skip the ISO download on a wipe of storage/ --- README.md | 1 + docker-compose.yml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index c2c39af..dd8f1df 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ Remove the old runners from GitHub (*Settings → Actions → Runners*) after th - Everything lives in [oem/install.bat](oem/install.bat), [oem/install.ps1](oem/install.ps1) and [docker-compose.yml](docker-compose.yml). There is no image to build. - To test script changes, edit `oem/`, delete `storage/` and run `docker compose up -d` again. For runner-only changes, re-run `install.ps1 -RunnersOnly` inside the VM. +- Every wipe of `storage/` downloads the 5 GB ISO again. To avoid that, download the stock evaluation ISO once (the URL is printed in `docker compose logs` at the start of a first install), keep it outside `storage/`, and enable the `/custom.iso` line in [docker-compose.yml](docker-compose.yml). Do not reuse the ISO from `storage/` for this: dockur has already baked `oem/` into it, so it would install the old scripts. - Pull requests run [lint.yml](.github/workflows/lint.yml): `docker compose config`, a PowerShell parse plus [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) on `install.ps1`, and actionlint on the workflows. - Dependabot keeps the GitHub Actions and the `dockurr/windows` tag current. diff --git a/docker-compose.yml b/docker-compose.yml index b01aea6..159e744 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,9 @@ services: volumes: - ./oem:/oem:ro # install.bat + install.ps1, copied to C:\OEM at install time - ./storage:/storage # Windows disk + install ISO, persists across restarts + # Stock Windows ISO kept outside storage/: a wipe of storage/ then skips + # the 5 GB download. Get the URL from the container log of a first start. + # - ./win2022.iso:/custom.iso:ro - ./shared:/shared # \\host.lan\Data (Z:) in the VM; install.log lands here # read by install.ps1. Every process in the VM, CI jobs included, can read # this share: comment the line out once the runners are registered. From 1110b719b772c83fd557bf4ed1cc108ad6abc18e Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 14:28:13 +0300 Subject: [PATCH 06/13] feat: mirror install progress to the shared folder while running --- README.md | 4 ++-- oem/install.ps1 | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dd8f1df..335cde2 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,13 @@ Two authentication methods are supported. Set **one** of them in `.env`: docker compose logs -f ``` -3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. Its log is mirrored to the host: +3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. The window itself shows nothing; progress is mirrored to the host, one line per step: ```bash tail -f shared/install.log ``` - `shared/install.done` or `shared/install.failed` appears when it finishes. + `shared/install.done` or `shared/install.failed` appears when it finishes, and the log is replaced by the full transcript, including the output of the installers. The first lines take a few minutes to appear while the shared folder comes up. > **First start takes 40 to 70 minutes**: ISO download (about 5 GB), Windows setup, Visual Studio, Rtools, runner registration. The runners register at the very end. Later starts take a minute or two and need no provisioning. diff --git a/oem/install.ps1 b/oem/install.ps1 index 7f511a7..4d40575 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -54,8 +54,16 @@ $SetupTask = 'GitHub runner setup' $Temp = Join-Path $env:TEMP 'runner-install' $RebootRequired = $false +# Progress lines go to the transcript and, while $MirrorLog is set, to the +# shared folder as well, so the host can follow a run that takes an hour. +# The full transcript replaces the mirrored lines when the run ends. +$MirrorLog = -not $AtLogon function Write-Log([string]$Message) { - Write-Host ('[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message) + $line = '[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message + Write-Host $line + if ($MirrorLog) { + try { Add-Content -LiteralPath "$Share\install.log" -Value $line } catch { Write-Host "Could not write to $Share ($_)" } + } } function Update-ProcessPath { @@ -360,6 +368,9 @@ try { return } } + # A real run from here on: give the host a fresh in-progress log. + $MirrorLog = $true + Remove-Item -LiteralPath "$Share\install.log", "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue Write-Log "Using settings from $configFile" # Idempotent and quick; also grows C: after a DISK_SIZE change on the host. From 7be0127c5cae6315146e33f1f53bda265d776fd3 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 14:38:11 +0300 Subject: [PATCH 07/13] fix: fresh transcript per run, remove surplus runners, stop listeners first - One transcript per run instead of appending forever; a logon check that finds nothing to do writes to install-logon.log so it never overwrites the log of the last real run. - Lowering RUNNERS now removes the runners above the new count from GitHub and deletes their folders. - Remove-Runner stops the interactive-mode task and listener before config.cmd remove and the folder deletion, which otherwise hit open files. --- README.md | 2 +- oem/install.ps1 | 49 +++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 335cde2..afda7e9 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Edit `.env` on the host and restart the container: docker compose down && docker compose up -d ``` -The restart matters: editors replace the file on save, and a bind-mounted file keeps pointing at the old copy until the container is recreated. At every logon the VM runs `install.ps1 -AtLogon` from a scheduled task named *GitHub runner setup*. It checks that every toolchain part is present (PowerShell 7, Chocolatey, Git, jq, 7-Zip, and Visual Studio and Rtools when enabled) and installs only what is missing. It then compares `runner.env` with the copy the runners were last registered from and, when it changed, removes the old registrations and re-registers with the new count, labels, version or mode. That is also how a wrong `PAT` or `RUNNER_URL` on the first start is fixed: correct `.env`, restart, wait for `shared/install.done`. When everything is present and nothing changed, the task exits without touching `shared/`. The result of the check is in `shared/install.log` as `Toolchain: all present` or `Toolchain: installing ...`. +The restart matters: editors replace the file on save, and a bind-mounted file keeps pointing at the old copy until the container is recreated. At every logon the VM runs `install.ps1 -AtLogon` from a scheduled task named *GitHub runner setup*. It checks that every toolchain part is present (PowerShell 7, Chocolatey, Git, jq, 7-Zip, and Visual Studio and Rtools when enabled) and installs only what is missing. It then compares `runner.env` with the copy the runners were last registered from and, when it changed, removes the old registrations and re-registers with the new count, labels, version or mode. Lowering `RUNNERS` removes the surplus runners from GitHub. That is also how a wrong `PAT` or `RUNNER_URL` on the first start is fixed: correct `.env`, restart, wait for `shared/install.done`. When everything is present and nothing changed, the task exits without touching `shared/`. The result of the check is in `shared/install.log` as `Toolchain: all present` or `Toolchain: installing ...`. The same can be done from an RDP session, in an elevated PowerShell and without a restart: diff --git a/oem/install.ps1 b/oem/install.ps1 index 4d40575..8a689b1 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -48,7 +48,8 @@ $ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest is much faster with [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $Share = '\\host.lan\Data' -$LogFile = 'C:\OEM\install.log' +$LogFile = 'C:\OEM\install.log' # last real run +$LogonLogFile = 'C:\OEM\install-logon.log' # last logon check that found nothing to do $ConfigMarker = 'C:\OEM\runner.env.sha256' # hash of the runner.env the runners were last registered from $SetupTask = 'GitHub runner setup' $Temp = Join-Path $env:TEMP 'runner-install' @@ -273,6 +274,25 @@ function Register-InteractiveRunner([int]$Index, [string]$Dir) { Start-ScheduledTask -TaskName $taskName } +# Takes runner $Index out of GitHub (its service or scheduled task with it) and +# deletes its folder. Safe to call when there is nothing to remove. +function Remove-Runner([int]$Index, [string[]]$Auth) { + $dir = "C:\runner-$Index" + $task = Get-ScheduledTask -TaskName "GitHub runner $Index" -ErrorAction SilentlyContinue + if ($task) { + $task | Stop-ScheduledTask -ErrorAction SilentlyContinue + $task | Unregister-ScheduledTask -Confirm:$false + } + # Interactive mode: the listener outlives its cmd window and holds files open + Get-Process Runner.Listener -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$dir\*" } | Stop-Process -Force + if (Test-Path -LiteralPath "$dir\.runner") { + Write-Log 'Removing the previous registration' + & "$dir\config.cmd" remove --unattended @Auth | Out-Host + if ($LASTEXITCODE -ne 0) { Write-Log "config.cmd remove exited with $LASTEXITCODE, continuing" } + } + if (Test-Path -LiteralPath $dir) { Remove-Item -LiteralPath $dir -Recurse -Force } +} + function Install-Runners($Config) { $url = Get-Setting $Config 'RUNNER_URL' $pat = Get-Setting $Config 'PAT' @@ -300,6 +320,14 @@ function Install-Runners($Config) { $pkgUrl = Get-Setting $Config 'GITHUB_RUNNER_URL' "https://github.com/actions/runner/releases/download/v$version/actions-runner-win-x64-$version.zip" Invoke-Download $pkgUrl $zip + # Runners from a previous run: the first $count are replaced in place, any + # above the new count (RUNNERS was lowered) are removed for good. + $existing = @(Get-ChildItem -LiteralPath 'C:\' -Directory -Filter 'runner-*' | ForEach-Object { [int]($_.Name -replace '^runner-', '') }) + foreach ($i in ($existing | Where-Object { $_ -gt $count })) { + Write-Log "Removing runner $i (RUNNERS is now $count)" + Remove-Runner $i $auth + } + $names = @() for ($i = 1; $i -le $count; $i++) { $dir = "C:\runner-$i" @@ -308,13 +336,7 @@ function Install-Runners($Config) { Write-Log "Configuring runner $i of ${count}: $name in $dir" # A previous registration (re-run) is removed first so --replace has nothing stale to fight. - if (Test-Path -LiteralPath "$dir\.runner") { - Write-Log 'Removing the previous registration' - & "$dir\config.cmd" remove --unattended @auth | Out-Host - if ($LASTEXITCODE -ne 0) { Write-Log "config.cmd remove exited with $LASTEXITCODE, continuing" } - } - Get-ScheduledTask -TaskName "GitHub runner $i" -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$false - if (Test-Path -LiteralPath $dir) { Remove-Item -LiteralPath $dir -Recurse -Force } + Remove-Runner $i $auth Expand-Archive -LiteralPath $zip -DestinationPath $dir -Force $cfgArgs = @('--unattended', '--replace', '--name', $name, '--url', $url, '--labels', $labels) + $auth @@ -340,7 +362,9 @@ if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrat throw 'Run this script from an elevated PowerShell' } -Start-Transcript -Path $LogFile -Append | Out-Null +# One transcript per run. A logon check writes to its own file so that a boot +# with nothing to do does not overwrite the log of the last real run. +Start-Transcript -Path $(if ($AtLogon) { $LogonLogFile } else { $LogFile }) | Out-Null New-Item -ItemType Directory -Path $Temp -Force | Out-Null $status = 'failed' try { @@ -368,7 +392,12 @@ try { return } } - # A real run from here on: give the host a fresh in-progress log. + # A real run from here on: switch to the main log and give the host a fresh + # in-progress copy of it. + if ($AtLogon) { + Stop-Transcript | Out-Null + Start-Transcript -Path $LogFile | Out-Null + } $MirrorLog = $true Remove-Item -LiteralPath "$Share\install.log", "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue Write-Log "Using settings from $configFile" From ad1f5ce94030377b447238cd4b97b3bd72a0dc76 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 14:40:27 +0300 Subject: [PATCH 08/13] fix: show install output in the Install window instead of redirecting it to a file --- README.md | 2 +- oem/install.bat | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index afda7e9..ed89d88 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Two authentication methods are supported. Set **one** of them in `.env`: docker compose logs -f ``` -3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. The window itself shows nothing; progress is mirrored to the host, one line per step: +3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. The window shows the live output, and the progress lines are mirrored to the host as well: ```bash tail -f shared/install.log diff --git a/oem/install.bat b/oem/install.bat index b2ec717..38004d1 100644 --- a/oem/install.bat +++ b/oem/install.bat @@ -1,8 +1,8 @@ @echo off rem dockur/windows copies this folder to C:\OEM and runs install.bat once, at the -rem end of the unattended installation, as the auto-logon administrator. -rem Everything happens in install.ps1. Its log is C:\OEM\install.log, copied to -rem the shared folder (./shared on the host) when it finishes. The redirect -rem below only catches PowerShell failing to start at all. -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" >> "%~dp0install.bat.log" 2>&1 +rem end of the unattended installation, as the auto-logon administrator, in a +rem visible "Install" window. Everything happens in install.ps1; its output is +rem shown here and recorded in C:\OEM\install.log, which is mirrored to the +rem shared folder (./shared on the host). +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" exit /b %ERRORLEVEL% From dc1e97de595bdb777ab5d661ca7e62050c9195dd Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 14:45:19 +0300 Subject: [PATCH 09/13] feat: same log on the console, in install.log and on the host - Invoke-Native runs choco, git, pacman and config.cmd so that their stdout and stderr are recorded in the transcript. Windows PowerShell does not record output a program writes straight to the console, and 2>&1 under ErrorActionPreference=Stop would abort at the first stderr line. - A background job copies the transcript to the shared folder every 10 s while a real run lasts, replacing the per-line mirroring of Write-Log. --- README.md | 4 ++-- oem/install.ps1 | 60 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index ed89d88..9832ef0 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,13 @@ Two authentication methods are supported. Set **one** of them in `.env`: docker compose logs -f ``` -3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. The window shows the live output, and the progress lines are mirrored to the host as well: +3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. The window shows the live output, and the same log is copied to the host every 10 seconds: ```bash tail -f shared/install.log ``` - `shared/install.done` or `shared/install.failed` appears when it finishes, and the log is replaced by the full transcript, including the output of the installers. The first lines take a few minutes to appear while the shared folder comes up. + `shared/install.done` or `shared/install.failed` appears when it finishes. The first lines take a few minutes to appear while the shared folder comes up. > **First start takes 40 to 70 minutes**: ISO download (about 5 GB), Windows setup, Visual Studio, Rtools, runner registration. The runners register at the very end. Later starts take a minute or two and need no provisioning. diff --git a/oem/install.ps1 b/oem/install.ps1 index 8a689b1..e74546b 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -55,15 +55,31 @@ $SetupTask = 'GitHub runner setup' $Temp = Join-Path $env:TEMP 'runner-install' $RebootRequired = $false -# Progress lines go to the transcript and, while $MirrorLog is set, to the -# shared folder as well, so the host can follow a run that takes an hour. -# The full transcript replaces the mirrored lines when the run ends. -$MirrorLog = -not $AtLogon function Write-Log([string]$Message) { - $line = '[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message - Write-Host $line - if ($MirrorLog) { - try { Add-Content -LiteralPath "$Share\install.log" -Value $line } catch { Write-Host "Could not write to $Share ($_)" } + Write-Host ('[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message) +} + +# Runs a native command so that its stdout and stderr end up in the transcript +# as well as on the console. Windows PowerShell does not record output a program +# writes straight to the console, and 2>&1 under ErrorActionPreference=Stop +# would abort the run at the first stderr line. Returns the exit code. +function Invoke-Native([string]$Exe, [string[]]$Arguments) { + $ErrorActionPreference = 'Continue' + & $Exe @Arguments 2>&1 | ForEach-Object { "$_" } | Out-Host + return $LASTEXITCODE +} + +# Copies the transcript to the shared folder every 10 seconds for as long as +# the run lasts, so the host sees the same log as the console. The transcript +# is copied once more after it is closed. +function Start-LogMirror { + $from = $LogFile + $to = "$Share\install.log" + return Start-Job -ScriptBlock { + while ($true) { + Copy-Item -LiteralPath $using:from -Destination $using:to -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 10 + } } } @@ -207,10 +223,10 @@ function Install-Toolchain($Config, [string[]]$Missing) { $wanted = @($packages.Keys | Where-Object { $Missing -contains $_ } | ForEach-Object { $packages[$_] }) if ($wanted) { Write-Log "Installing $($wanted -join ', ') with Chocolatey" - choco install -y --no-progress @wanted - if ($LASTEXITCODE -notin 0, 1641, 3010) { throw "choco install failed with exit code $LASTEXITCODE" } + $code = Invoke-Native 'choco' (@('install', '-y', '--no-progress') + $wanted) + if ($code -notin 0, 1641, 3010) { throw "choco install failed with exit code $code" } Update-ProcessPath - git config --system core.longpaths true + Invoke-Native 'git' @('config', '--system', 'core.longpaths', 'true') | Out-Null } if ($Missing -contains 'Visual Studio') { @@ -247,8 +263,8 @@ function Install-Rtools { Add-MachinePath 'C:\rtools40\usr\bin', 'C:\rtools40\mingw64\bin' Write-Log 'Installing mingw-w64-x86_64-make' - & 'C:\rtools40\usr\bin\pacman.exe' -Sy --noconfirm mingw-w64-x86_64-make - if ($LASTEXITCODE -ne 0) { throw "pacman failed with exit code $LASTEXITCODE" } + $code = Invoke-Native 'C:\rtools40\usr\bin\pacman.exe' @('-Sy', '--noconfirm', 'mingw-w64-x86_64-make') + if ($code -ne 0) { throw "pacman failed with exit code $code" } } # --- Runners ----------------------------------------------------------------- @@ -287,8 +303,8 @@ function Remove-Runner([int]$Index, [string[]]$Auth) { Get-Process Runner.Listener -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$dir\*" } | Stop-Process -Force if (Test-Path -LiteralPath "$dir\.runner") { Write-Log 'Removing the previous registration' - & "$dir\config.cmd" remove --unattended @Auth | Out-Host - if ($LASTEXITCODE -ne 0) { Write-Log "config.cmd remove exited with $LASTEXITCODE, continuing" } + $code = Invoke-Native "$dir\config.cmd" (@('remove', '--unattended') + $Auth) + if ($code -ne 0) { Write-Log "config.cmd remove exited with $code, continuing" } } if (Test-Path -LiteralPath $dir) { Remove-Item -LiteralPath $dir -Recurse -Force } } @@ -345,8 +361,8 @@ function Install-Runners($Config) { $cfgArgs += @('--runasservice', '--windowslogonaccount', $serviceAccount) if (-not $builtinAccount) { $cfgArgs += @('--windowslogonpassword', $servicePassword) } } - & "$dir\config.cmd" @cfgArgs | Out-Host - if ($LASTEXITCODE -ne 0) { throw "config.cmd failed for $name with exit code $LASTEXITCODE" } + $code = Invoke-Native "$dir\config.cmd" $cfgArgs + if ($code -ne 0) { throw "config.cmd failed for $name with exit code $code" } if ($mode -eq 'interactive') { Register-InteractiveRunner $i $dir } } @@ -367,6 +383,7 @@ if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrat Start-Transcript -Path $(if ($AtLogon) { $LogonLogFile } else { $LogFile }) | Out-Null New-Item -ItemType Directory -Path $Temp -Force | Out-Null $status = 'failed' +$mirror = $null try { # Registered before anything that can fail, so a bad runner.env still gets # a retry at the next boot. Not re-registered from the task itself. @@ -392,14 +409,14 @@ try { return } } - # A real run from here on: switch to the main log and give the host a fresh - # in-progress copy of it. + # A real run from here on: switch to the main log, clear the old markers and + # start mirroring the log to the host. if ($AtLogon) { Stop-Transcript | Out-Null Start-Transcript -Path $LogFile | Out-Null } - $MirrorLog = $true - Remove-Item -LiteralPath "$Share\install.log", "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue + $mirror = Start-LogMirror Write-Log "Using settings from $configFile" # Idempotent and quick; also grows C: after a DISK_SIZE change on the host. @@ -429,6 +446,7 @@ try { Write-Log $_.ScriptStackTrace } finally { Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue + if ($mirror) { $mirror | Stop-Job; $mirror | Remove-Job -Force } Stop-Transcript | Out-Null # Report back to the host through the shared folder (./shared). A logon run From e559f4b6554e5e40f7714e4b33fb4d1231de1984 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 14:45:40 +0300 Subject: [PATCH 10/13] chore: mirror the install log every 2 seconds --- README.md | 2 +- oem/install.ps1 | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9832ef0..84611db 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Two authentication methods are supported. Set **one** of them in `.env`: docker compose logs -f ``` -3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. The window shows the live output, and the same log is copied to the host every 10 seconds: +3. Open `http://:8006` to watch the Windows installation. When the desktop appears, a visible `Install` window runs `C:\OEM\install.bat`. The window shows the live output, and the same log is copied to the host every 2 seconds: ```bash tail -f shared/install.log diff --git a/oem/install.ps1 b/oem/install.ps1 index e74546b..48325f9 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -69,7 +69,7 @@ function Invoke-Native([string]$Exe, [string[]]$Arguments) { return $LASTEXITCODE } -# Copies the transcript to the shared folder every 10 seconds for as long as +# Copies the transcript to the shared folder every 2 seconds for as long as # the run lasts, so the host sees the same log as the console. The transcript # is copied once more after it is closed. function Start-LogMirror { @@ -78,7 +78,7 @@ function Start-LogMirror { return Start-Job -ScriptBlock { while ($true) { Copy-Item -LiteralPath $using:from -Destination $using:to -Force -ErrorAction SilentlyContinue - Start-Sleep -Seconds 10 + Start-Sleep -Seconds 2 } } } From bb49037f0033622bcd41c46e916e1e9a8e167778 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 15:00:13 +0300 Subject: [PATCH 11/13] fix: append-only log mirror so 'tail -f' on the host is not truncated Copy-Item rewrote shared/install.log every 2 s, which tail -f reported as "file truncated" on every pass. The mirror job now remembers how much of the transcript it has sent and appends only the new bytes. The final overwrite is gone too; the job gets one more pass after Stop-Transcript. --- oem/install.ps1 | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/oem/install.ps1 b/oem/install.ps1 index 48325f9..e4a9a0e 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -69,15 +69,29 @@ function Invoke-Native([string]$Exe, [string[]]$Arguments) { return $LASTEXITCODE } -# Copies the transcript to the shared folder every 2 seconds for as long as -# the run lasts, so the host sees the same log as the console. The transcript -# is copied once more after it is closed. +# Appends whatever the transcript gained to the copy in the shared folder every +# 2 seconds, so the host sees the same log as the console and 'tail -f' keeps +# working: the copy only grows, it is never rewritten. function Start-LogMirror { $from = $LogFile $to = "$Share\install.log" + Remove-Item -LiteralPath $to -Force -ErrorAction SilentlyContinue return Start-Job -ScriptBlock { + $offset = 0 while ($true) { - Copy-Item -LiteralPath $using:from -Destination $using:to -Force -ErrorAction SilentlyContinue + try { + $src = [IO.File]::Open($using:from, 'Open', 'Read', 'ReadWrite') + try { + if ($src.Length -gt $offset) { + $buffer = New-Object byte[] ($src.Length - $offset) + $src.Seek($offset, 'Begin') | Out-Null + $read = $src.Read($buffer, 0, $buffer.Length) + $dst = [IO.File]::Open($using:to, 'Append', 'Write', 'Read') + try { $dst.Write($buffer, 0, $read) } finally { $dst.Close() } + $offset += $read + } + } finally { $src.Close() } + } catch { Write-Verbose "$_" } Start-Sleep -Seconds 2 } } @@ -446,14 +460,19 @@ try { Write-Log $_.ScriptStackTrace } finally { Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue - if ($mirror) { $mirror | Stop-Job; $mirror | Remove-Job -Force } Stop-Transcript | Out-Null + if ($mirror) { + Start-Sleep -Seconds 3 # one more pass of the mirror picks up the transcript footer + $mirror | Stop-Job + $mirror | Remove-Job -Force + } # Report back to the host through the shared folder (./shared). A logon run # that found nothing to do leaves the previous log and marker alone. if ($status -ne 'skipped') { try { - Copy-Item -LiteralPath $LogFile -Destination "$Share\install.log" -Force + # Without a mirror (failed before the run started) the host has no log yet. + if (-not $mirror) { Copy-Item -LiteralPath $LogFile -Destination "$Share\install.log" -Force } Remove-Item -LiteralPath "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue Set-Content -Path "$Share\install.$status" -Value (Get-Date -Format o) } catch { From 627cf3161b673bc29c6ebe1bc9ed1f7e585c21b9 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 15:10:41 +0300 Subject: [PATCH 12/13] fix: resolve the latest runner version without the GitHub REST API The unauthenticated API allows 60 calls per hour per public IP, shared with everything else behind that IP, and a first install failed on it at the very last step. The releases/latest page redirects to the tag URL, which carries the version and is not rate limited. --- oem/install.ps1 | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/oem/install.ps1 b/oem/install.ps1 index e4a9a0e..3488578 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -286,8 +286,16 @@ function Install-Rtools { function Resolve-RunnerVersion([string]$Version) { if ($Version) { return $Version.TrimStart('v') } Write-Log 'GITHUB_RUNNER_VERSION not set, using the latest actions/runner release' - $release = Invoke-RestMethod 'https://api.github.com/repos/actions/runner/releases/latest' -Headers @{ 'User-Agent' = 'windows-github-custom-runner' } - return $release.tag_name.TrimStart('v') + # The releases/latest page redirects to the tag. Unlike the REST API this has + # no rate limit, which an unauthenticated call shares with everyone behind + # the same public IP (60 per hour). + $request = [Net.WebRequest]::Create('https://github.com/actions/runner/releases/latest') + $request.Method = 'HEAD' + $request.AllowAutoRedirect = $false + $response = $request.GetResponse() + try { $location = $response.Headers['Location'] } finally { $response.Close() } + if ($location -notmatch '/tag/v?([\d.]+)$') { throw "Could not determine the latest actions/runner release from '$location'. Set GITHUB_RUNNER_VERSION in .env." } + return $Matches[1] } # Interactive mode: run.cmd as a scheduled task in the auto-logon desktop session, From e325ee0039e71446149465545a554cee1d12c160 Mon Sep 17 00:00:00 2001 From: vaggeliskls Date: Wed, 7 Oct 2026 15:24:24 +0300 Subject: [PATCH 13/13] chore: satisfy PSScriptAnalyzer (pass ConfigPath explicitly, rename Write-Log) --- oem/install.ps1 | 62 ++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/oem/install.ps1 b/oem/install.ps1 index 3488578..b2de7e3 100644 --- a/oem/install.ps1 +++ b/oem/install.ps1 @@ -55,7 +55,7 @@ $SetupTask = 'GitHub runner setup' $Temp = Join-Path $env:TEMP 'runner-install' $RebootRequired = $false -function Write-Log([string]$Message) { +function Write-Step([string]$Message) { Write-Host ('[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message) } @@ -127,8 +127,8 @@ function Read-EnvFile([string]$Path) { } # Returns the path of runner.env, or $null with -Optional when there is none. -function Find-Config([switch]$Optional) { - if ($ConfigPath) { return $ConfigPath } +function Find-Config([string]$Override, [switch]$Optional) { + if ($Override) { return $Override } $candidates = @("$Share\runner.env", 'Z:\runner.env', 'C:\OEM\runner.env') # The samba share can take a moment to become reachable after logon. $deadline = (Get-Date).AddMinutes(5) @@ -174,14 +174,14 @@ function Test-Enabled($Config, [string]$Key, [bool]$Default = $true) { } function Invoke-Download([string]$Uri, [string]$OutFile) { - Write-Log "Downloading $Uri" + Write-Step "Downloading $Uri" Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing } # --- Toolchain --------------------------------------------------------------- function Initialize-System { - Write-Log 'Enabling long paths' + Write-Step 'Enabling long paths' New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 -PropertyType DWORD -Force | Out-Null # Grow C: when DISK_SIZE is larger than the partition dockur created @@ -189,11 +189,11 @@ function Initialize-System { $max = (Get-PartitionSupportedSize -DriveLetter C).SizeMax $cur = (Get-Partition -DriveLetter C).Size if (($max - $cur) -gt 1GB) { - Write-Log 'Growing C: to the full virtual disk' + Write-Step 'Growing C: to the full virtual disk' Resize-Partition -DriveLetter C -Size $max } } catch { - Write-Log "Could not resize C: ($_)" + Write-Step "Could not resize C: ($_)" } } @@ -223,12 +223,12 @@ function Get-MissingTools($Config) { function Install-Toolchain($Config, [string[]]$Missing) { if ($Missing -contains 'PowerShell 7') { - Write-Log 'Installing PowerShell 7' + Write-Step 'Installing PowerShell 7' Invoke-Expression "& { $(Invoke-RestMethod 'https://aka.ms/install-powershell.ps1') } -UseMSI -Quiet" } if ($Missing -contains 'Chocolatey') { - Write-Log 'Installing Chocolatey' + Write-Step 'Installing Chocolatey' Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) Update-ProcessPath } @@ -236,7 +236,7 @@ function Install-Toolchain($Config, [string[]]$Missing) { $packages = @{ 'Git' = 'git.install'; 'jq' = 'jq'; '7-Zip' = '7zip.install' } $wanted = @($packages.Keys | Where-Object { $Missing -contains $_ } | ForEach-Object { $packages[$_] }) if ($wanted) { - Write-Log "Installing $($wanted -join ', ') with Chocolatey" + Write-Step "Installing $($wanted -join ', ') with Chocolatey" $code = Invoke-Native 'choco' (@('install', '-y', '--no-progress') + $wanted) if ($code -notin 0, 1641, 3010) { throw "choco install failed with exit code $code" } Update-ProcessPath @@ -258,11 +258,11 @@ function Install-VisualStudio([string]$Edition, [string]$Workloads) { $vsArgs += @('--add', $workload) } - Write-Log "Installing Visual Studio 2022 $Edition with $Workloads (this takes a while)" + Write-Step "Installing Visual Studio 2022 $Edition with $Workloads (this takes a while)" $proc = Start-Process -FilePath $exe -ArgumentList $vsArgs -Wait -PassThru switch ($proc.ExitCode) { - 0 { Write-Log 'Visual Studio installed' } - 3010 { Write-Log 'Visual Studio installed, reboot required'; $script:RebootRequired = $true } + 0 { Write-Step 'Visual Studio installed' } + 3010 { Write-Step 'Visual Studio installed, reboot required'; $script:RebootRequired = $true } default { throw "Visual Studio installer failed with exit code $($proc.ExitCode)" } } } @@ -271,12 +271,12 @@ function Install-Rtools { $exe = Join-Path $Temp 'rtools40.exe' Invoke-Download 'https://cran.r-project.org/bin/windows/Rtools/rtools40-x86_64.exe' $exe - Write-Log 'Installing Rtools 4.0' + Write-Step 'Installing Rtools 4.0' $proc = Start-Process -FilePath $exe -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait -PassThru if ($proc.ExitCode -ne 0) { throw "Rtools installer failed with exit code $($proc.ExitCode)" } Add-MachinePath 'C:\rtools40\usr\bin', 'C:\rtools40\mingw64\bin' - Write-Log 'Installing mingw-w64-x86_64-make' + Write-Step 'Installing mingw-w64-x86_64-make' $code = Invoke-Native 'C:\rtools40\usr\bin\pacman.exe' @('-Sy', '--noconfirm', 'mingw-w64-x86_64-make') if ($code -ne 0) { throw "pacman failed with exit code $code" } } @@ -285,7 +285,7 @@ function Install-Rtools { function Resolve-RunnerVersion([string]$Version) { if ($Version) { return $Version.TrimStart('v') } - Write-Log 'GITHUB_RUNNER_VERSION not set, using the latest actions/runner release' + Write-Step 'GITHUB_RUNNER_VERSION not set, using the latest actions/runner release' # The releases/latest page redirects to the tag. Unlike the REST API this has # no rate limit, which an unauthenticated call shares with everyone behind # the same public IP (60 per hour). @@ -324,9 +324,9 @@ function Remove-Runner([int]$Index, [string[]]$Auth) { # Interactive mode: the listener outlives its cmd window and holds files open Get-Process Runner.Listener -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$dir\*" } | Stop-Process -Force if (Test-Path -LiteralPath "$dir\.runner") { - Write-Log 'Removing the previous registration' + Write-Step 'Removing the previous registration' $code = Invoke-Native "$dir\config.cmd" (@('remove', '--unattended') + $Auth) - if ($code -ne 0) { Write-Log "config.cmd remove exited with $code, continuing" } + if ($code -ne 0) { Write-Step "config.cmd remove exited with $code, continuing" } } if (Test-Path -LiteralPath $dir) { Remove-Item -LiteralPath $dir -Recurse -Force } } @@ -362,7 +362,7 @@ function Install-Runners($Config) { # above the new count (RUNNERS was lowered) are removed for good. $existing = @(Get-ChildItem -LiteralPath 'C:\' -Directory -Filter 'runner-*' | ForEach-Object { [int]($_.Name -replace '^runner-', '') }) foreach ($i in ($existing | Where-Object { $_ -gt $count })) { - Write-Log "Removing runner $i (RUNNERS is now $count)" + Write-Step "Removing runner $i (RUNNERS is now $count)" Remove-Runner $i $auth } @@ -371,7 +371,7 @@ function Install-Runners($Config) { $dir = "C:\runner-$i" $name = "${prefix}_${hostId}_$i" $names += $name - Write-Log "Configuring runner $i of ${count}: $name in $dir" + Write-Step "Configuring runner $i of ${count}: $name in $dir" # A previous registration (re-run) is removed first so --replace has nothing stale to fight. Remove-Runner $i $auth @@ -411,9 +411,9 @@ try { # a retry at the next boot. Not re-registered from the task itself. if (-not $AtLogon) { Register-SetupTask } - $configFile = Find-Config -Optional:$AtLogon + $configFile = Find-Config $ConfigPath -Optional:$AtLogon if (-not $configFile) { - Write-Log 'runner.env not found, nothing to do' + Write-Step 'runner.env not found, nothing to do' $status = 'skipped' return } @@ -439,20 +439,20 @@ try { } Remove-Item -LiteralPath "$Share\install.done", "$Share\install.failed" -Force -ErrorAction SilentlyContinue $mirror = Start-LogMirror - Write-Log "Using settings from $configFile" + Write-Step "Using settings from $configFile" # Idempotent and quick; also grows C: after a DISK_SIZE change on the host. Initialize-System - if ($RunnersOnly) { Write-Log 'Toolchain: skipped (-RunnersOnly)' } - elseif ($missing) { Write-Log "Toolchain: installing $($missing -join ', ')"; Install-Toolchain $config $missing } - else { Write-Log 'Toolchain: all present' } + if ($RunnersOnly) { Write-Step 'Toolchain: skipped (-RunnersOnly)' } + elseif ($missing) { Write-Step "Toolchain: installing $($missing -join ', ')"; Install-Toolchain $config $missing } + else { Write-Step 'Toolchain: all present' } if ($registerRunners) { $result = Install-Runners $config Set-Content -LiteralPath $ConfigMarker -Value (Get-ConfigHash $configFile) - Write-Log "Registered $($result.Names.Count) runner(s), actions/runner $($result.Version), mode $($result.Mode): $($result.Names -join ', ')" + Write-Step "Registered $($result.Names.Count) runner(s), actions/runner $($result.Version), mode $($result.Mode): $($result.Names -join ', ')" } else { - Write-Log 'Runners: runner.env unchanged, left as they are' + Write-Step 'Runners: runner.env unchanged, left as they are' } # Only the fallback location is on the Windows disk; do not leave a PAT there. @@ -460,12 +460,12 @@ try { $status = 'done' if ($RebootRequired) { - Write-Log 'Rebooting in 60 seconds to finish the Visual Studio installation' + Write-Step 'Rebooting in 60 seconds to finish the Visual Studio installation' shutdown.exe /r /t 60 /c 'Finishing GitHub runner setup' } } catch { - Write-Log "FAILED: $_" - Write-Log $_.ScriptStackTrace + Write-Step "FAILED: $_" + Write-Step $_.ScriptStackTrace } finally { Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue Stop-Transcript | Out-Null