Build Windows qcow2 #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |