Skip to content

Build Windows qcow2

Build Windows qcow2 #7

Workflow file for this run

name: Build Windows qcow2
on:
workflow_dispatch:
inputs:
version_tag:
description: "OCI tag for ghcr.io/CMGS/windows"
required: true
default: "win11-25h2"
disk_size:
description: "Disk image size"
required: true
default: "40G"
env:
VIRTIO_WIN_URL: "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-0.1.285-1/virtio-win-0.1.285.iso"
SSH_PORT: 2222
QCOW2_NAME: "windows-11-25h2.qcow2"
# Windows auto-logon password from autounattend.xml (base64(UTF16LE("C@c#on160Password")))
WIN_PASS: "C@c#on160"
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 350
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/share/boost /opt/ghc \
/usr/local/lib/android /opt/hostedtoolcache/CodeQL
sudo apt-get purge -y '^ghc-' '^dotnet-' '^llvm-' 'php.*' 'mysql.*' \
'^postgresql.*' azure-cli google-cloud-cli firefox 2>/dev/null || true
sudo apt-get autoremove -y
sudo apt-get clean
df -h /
- name: Verify KVM
run: |
sudo apt-get update
sudo apt-get install -y cpu-checker
sudo kvm-ok
ls -la /dev/kvm
sudo chmod 666 /dev/kvm
- name: Install dependencies
run: |
sudo apt-get install -y --no-install-recommends \
qemu-system-x86 qemu-utils \
ovmf swtpm mtools genisoimage \
openssh-client sshpass netcat-openbsd
- name: Install oras
run: |
VERSION="1.2.0"
curl -LO "https://github.com/oras-project/oras/releases/download/v${VERSION}/oras_${VERSION}_linux_amd64.tar.gz"
mkdir -p oras-install
tar -xzf "oras_${VERSION}_linux_amd64.tar.gz" -C oras-install
sudo mv oras-install/oras /usr/local/bin/
rm -rf oras-install "oras_${VERSION}_linux_amd64.tar.gz"
oras version
- name: Download Windows ISO
env:
WINDOWS_ISO_URL: ${{ secrets.WINDOWS_ISO_URL }}
run: |
test -n "$WINDOWS_ISO_URL" || { echo "::error::WINDOWS_ISO_URL secret not set"; exit 1; }
curl -fsSL -o windows.iso "$WINDOWS_ISO_URL"
ls -lh windows.iso
- name: Download virtio-win ISO
run: |
curl -fsSL -o virtio-win.iso "$VIRTIO_WIN_URL"
ls -lh virtio-win.iso
- name: Create disk image
run: qemu-img create -f qcow2 "$QCOW2_NAME" "${{ inputs.disk_size }}"
- name: Prepare OVMF (Secure Boot variant)
run: cp /usr/share/OVMF/OVMF_VARS_4M.fd OVMF_VARS.fd
- name: Start TPM emulator
run: |
mkdir -p /tmp/mytpm
swtpm socket --tpmstate dir=/tmp/mytpm \
--ctrl type=unixio,path=/tmp/swtpm-sock \
--tpm2 --log level=5 &
sleep 2
test -S /tmp/swtpm-sock
- name: Create autounattend ISO
run: |
genisoimage -o autounattend.iso -J -r autounattend.xml
ls -lh autounattend.iso
- name: Launch QEMU (Windows installer)
# Secure Boot OVMF + smm required for Win11 hardware check
# Explicit ide-cd on separate ide.N buses (q35 AHCI needs individual ports)
# -display none + -monitor tcp for headless with keypress injection
run: |
qemu-system-x86_64 \
-machine q35,accel=kvm,smm=on \
-cpu host,hv_relaxed,hv_spinlocks=0x1fff,hv_vapic,hv_time \
-m 8G -smp 4 \
-global driver=cfi.pflash01,property=secure,value=on \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.secboot.fd \
-drive if=pflash,format=raw,file=OVMF_VARS.fd \
-drive id=cd0,if=none,file=windows.iso,media=cdrom,readonly=on \
-device ide-cd,drive=cd0,bus=ide.0,bootindex=0 \
-drive id=cd1,if=none,file=virtio-win.iso,media=cdrom,readonly=on \
-device ide-cd,drive=cd1,bus=ide.1 \
-drive id=cd2,if=none,file=autounattend.iso,media=cdrom,readonly=on \
-device ide-cd,drive=cd2,bus=ide.2 \
-drive if=none,id=root,file="$QCOW2_NAME",format=qcow2 \
-device virtio-blk-pci,drive=root,disable-legacy=on \
-device virtio-net-pci,netdev=mynet0,disable-legacy=on \
-netdev user,id=mynet0,hostfwd=tcp::${SSH_PORT}-:22 \
-chardev socket,id=chrtpm,path=/tmp/swtpm-sock \
-tpmdev emulator,id=tpm0,chardev=chrtpm \
-device tpm-tis,tpmdev=tpm0 \
-display none \
-serial file:serial.log \
-monitor tcp:127.0.0.1:4444,server,nowait \
-daemonize -pidfile qemu.pid
echo "QEMU started, PID=$(cat qemu.pid)"
- name: Defeat "Press any key to boot from CD" prompt
# Spam Enter keys 2s..40s after start so one lands in the Windows
# bootloader timeout window (~5s from OVMF load). GitHub-hosted
# ubuntu-latest runners are noticeably slower than GCP n2 VMs at
# cold OVMF init, so the original 2..17s window was too tight --
# this one covers up to 40s which is still comfortably before the
# Alt+N picker spray kicks in.
run: |
for delay in 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2; do
sleep $delay
echo 'sendkey ret' | nc -w 1 -q 1 127.0.0.1 4444 >/dev/null
done
echo "Enter keys sent"
- name: Click past Win11 25H2 Setup language + keyboard pickers
# Windows 11 24H2+ modernized Setup (SetupHost.exe) shows a mandatory
# "Select language settings" screen followed by "Select keyboard settings"
# BEFORE it reads autounattend.xml. Nothing you can put in the unattend
# XML skips these two screens -- not SetupUILanguage, not SystemLocale,
# nothing. See "Quirk #5" in README.md.
#
# Fix: blindly press Alt+N (the underlined accelerator for the Next
# button) every few seconds. The pickers pre-populate with the ISO's
# locale so "Next" accepts sane defaults. Stop once the disk starts
# growing -- that signals Setup has read autounattend.xml and begun
# partitioning / file copy.
run: |
# 120 iterations x 3s = 6 min window (was 60 iterations = 3 min).
# GCP n2 VMs reach the second picker within ~90 s; ubuntu-latest
# runners may need twice that.
for i in $(seq 1 120); do
sleep 3
echo 'sendkey alt-n' | nc -w 1 -q 1 127.0.0.1 4444 >/dev/null
DISK_K=$(du -k "$QCOW2_NAME" | cut -f1)
if [ "$DISK_K" -gt 500000 ]; then
echo "Setup past pickers (disk=${DISK_K}K at iter=$i), stopping Alt+N spray"
break
fi
done
if [ "$DISK_K" -le 500000 ]; then
echo "::error::Disk still ${DISK_K}K after 120 Alt+N presses, Setup did not advance"
echo 'screendump /tmp/screen.ppm' | nc -w 1 -q 1 127.0.0.1 4444 || true
ls -la /tmp/screen.ppm 2>/dev/null || true
exit 1
fi
- name: Wait for install.success marker
# ConnectTimeout=30 (not 5) + 3 retries per iteration. During heavy
# FirstLogonCommands (TiWorker / DISM / virtio-win guest tools install)
# the Windows SSH server can take >5s to accept connections, and a
# short timeout would silently fail on every iteration and never
# detect the marker -- validated on GCP test build where install.success
# existed 11 min before the old 5s poll loop noticed.
run: |
MAX_WAIT=7200
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
sleep 60
ELAPSED=$((ELAPSED + 60))
if ! kill -0 $(cat qemu.pid) 2>/dev/null; then
echo "::error::QEMU died"
tail -40 serial.log
exit 1
fi
DISK=$(du -sh "$QCOW2_NAME" | cut -f1)
FOUND=""
for try in 1 2 3; do
if sshpass -p "$WIN_PASS" ssh -o StrictHostKeyChecking=no \
-o ConnectTimeout=30 -o ServerAliveInterval=10 \
-o UserKnownHostsFile=/dev/null \
-p "$SSH_PORT" cocoon@localhost \
"if exist C:\\install.success echo READY" 2>/dev/null | grep -q READY; then
FOUND=1
break
fi
sleep 2
done
if [ -n "$FOUND" ]; then
echo "install.success detected at ${ELAPSED}s (disk=${DISK})"
exit 0
fi
echo "[${ELAPSED}s] waiting... disk=${DISK}"
done
echo "::error::Timed out after ${MAX_WAIT}s"
exit 1
- name: Eject install CDs via QEMU monitor
# After the install finishes, the CDs MUST be ejected from the QEMU
# monitor before the first VM reboot. Reason: we pin the Windows ISO
# to bootindex=0 so OVMF always tries Boot0001 first; on the initial
# install that is exactly what we want (key spray defeats "Press any
# key to boot from CD"), but on a warm reboot Windows bootmgr still
# renders the prompt, still times out, and OVMF then *re-loops* on
# Boot0001 instead of falling through to the disk entry. Result: the
# VM sits forever at two pegged CPUs in OVMF BDS retrying the CD, the
# post-reboot `wait_for_ssh` hits its 5 min timeout and the job fails.
#
# Using `eject` + `change ... none` via the monitor removes the CDs
# from the boot order and OVMF picks Boot0002 (Windows Boot Manager
# on the virtio-blk disk) immediately on the next reboot.
run: |
printf 'eject -f cd0\n' | nc -w 1 -q 1 127.0.0.1 4444
printf 'eject -f cd1\n' | nc -w 1 -q 1 127.0.0.1 4444
printf 'eject -f cd2\n' | nc -w 1 -q 1 127.0.0.1 4444 2>/dev/null || true
printf 'info block\n' | nc -w 1 -q 1 127.0.0.1 4444
- name: Verify and remediate
run: |
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o UserKnownHostsFile=/dev/null"
ssh_run() { sshpass -p "$WIN_PASS" ssh $SSH_OPTS -p "$SSH_PORT" cocoon@localhost "$@"; }
scp_to() { sshpass -p "$WIN_PASS" scp $SSH_OPTS -P "$SSH_PORT" "$@"; }
wait_for_ssh() {
echo "Waiting for SSH..."
sleep 30
for i in $(seq 1 60); do
if ssh_run "echo ok" 2>/dev/null | grep -q ok; then
echo "SSH back after ~$((30 + i * 5))s"
sleep 15
return 0
fi
sleep 5
done
return 1
}
reboot_vm() { ssh_run "shutdown /r /t 5" 2>/dev/null || true; wait_for_ssh; }
ssh_run "mkdir C:\\scripts" 2>/dev/null || true
scp_to scripts/verify.ps1 scripts/remediate.ps1 cocoon@localhost:"C:\\scripts\\"
echo "=== Pre-reboot verification ==="
ssh_run "powershell -ExecutionPolicy Bypass -File C:\\scripts\\verify.ps1" || true
reboot_vm
for attempt in 1 2 3; do
echo "=== Post-reboot verification (attempt $attempt/3) ==="
if ssh_run "powershell -ExecutionPolicy Bypass -File C:\\scripts\\verify.ps1"; then
echo "All checks passed!"
exit 0
fi
[ "$attempt" -eq 3 ] && { echo "::error::Verify still failing"; exit 1; }
echo "=== Remediating ==="
ssh_run "powershell -ExecutionPolicy Bypass -File C:\\scripts\\remediate.ps1"
reboot_vm
done
- name: Shut down VM
run: |
sshpass -p "$WIN_PASS" ssh \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-p "$SSH_PORT" cocoon@localhost \
"shutdown /s /t 10" 2>/dev/null || true
QEMU_PID=$(cat qemu.pid)
for i in $(seq 1 90); do
kill -0 "$QEMU_PID" 2>/dev/null || { echo "VM down after ${i}s"; break; }
sleep 1
done
kill -0 "$QEMU_PID" 2>/dev/null && { kill -9 "$QEMU_PID"; sleep 2; }
- name: Free disk before compression
run: |
rm -f windows.iso virtio-win.iso autounattend.iso
df -h /
- name: Compress qcow2
run: |
echo "Original: $(du -sh $QCOW2_NAME | cut -f1)"
qemu-img convert -O qcow2 -c "$QCOW2_NAME" "${QCOW2_NAME}.compressed"
mv "${QCOW2_NAME}.compressed" "$QCOW2_NAME"
echo "Compressed: $(du -sh $QCOW2_NAME | cut -f1)"
- name: Split qcow2 into OCI blob chunks
# ~1.9 GiB per chunk to stay well under any GHCR blob limit
run: |
split -b 1900M -d --additional-suffix=.qcow2.part "$QCOW2_NAME" "${QCOW2_NAME}."
rm "$QCOW2_NAME"
ls -lh "${QCOW2_NAME}."*
sha256sum "${QCOW2_NAME}."* > SHA256SUMS
cat SHA256SUMS
- name: Push to GHCR via ORAS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "$GITHUB_TOKEN" | oras login ghcr.io -u "${{ github.actor }}" --password-stdin
REPO_LC=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
TAG="${{ inputs.version_tag }}-$(date -u +%Y%m%d)"
REF="ghcr.io/${REPO_LC}:${TAG}"
# Assemble --file arguments for each part with media type
ARGS=()
for part in "${QCOW2_NAME}."*; do
ARGS+=("${part}:application/vnd.cocoonstack.windows.disk.qcow2.part")
done
ARGS+=("SHA256SUMS:text/plain")
oras push "$REF" \
--artifact-type "application/vnd.cocoonstack.windows-image.v1+json" \
--annotation "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "org.opencontainers.image.description=Windows 11 25H2 qcow2, split parts" \
--annotation "cocoonstack.windows.reassemble=cat ${QCOW2_NAME}.*.qcow2.part > ${QCOW2_NAME}" \
"${ARGS[@]}"
# Also push "latest" tag for the version family
oras tag "$REF" "${{ inputs.version_tag }}"
echo "Pushed: $REF"
echo "Pushed: ghcr.io/${REPO_LC}:${{ inputs.version_tag }}"
- name: Capture diagnostic screenshot
if: always()
run: |
if [ -f qemu.pid ] && kill -0 "$(cat qemu.pid)" 2>/dev/null; then
echo 'screendump screen.ppm' | nc -w 1 -q 1 127.0.0.1 4444 || true
if [ -f screen.ppm ]; then
sudo apt-get install -y -qq imagemagick 2>/dev/null || true
convert screen.ppm screen.png 2>/dev/null || true
fi
fi
- name: Upload debug artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: debug-${{ github.run_id }}
if-no-files-found: ignore
retention-days: 7
path: |
serial.log
screen.png
screen.ppm
- name: Cleanup
if: always()
run: |
kill $(cat qemu.pid 2>/dev/null) 2>/dev/null || true
sudo rm -rf /tmp/mytpm