Skip to content
Open
26 changes: 4 additions & 22 deletions .github/workflows/build-windows-a11y-ami.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,21 +75,7 @@ jobs:

- name: Install Windows Updates (repeat until converged)
run: |
for i in $(seq 1 5); do
OUTPUT=$(bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/install-updates.ps1 3600)
echo "${OUTPUT}"
if echo "${OUTPUT}" | grep -q "No updates found."; then
echo "No further updates."
break
fi
if echo "${OUTPUT}" | grep -q "REBOOT_REQUIRED=true"; then
echo "Rebooting instance for updates (pass ${i})..."
aws ec2 reboot-instances --instance-ids "${{ steps.launch.outputs.instance_id }}"
sleep 30
aws ec2 wait instance-status-ok --instance-ids "${{ steps.launch.outputs.instance_id }}"
fi
done
echo "WINDOWS_UPDATE_DATE=$(date -u +%Y-%m-%d)" >> "$GITHUB_ENV"
bash scripts/windows-a11y/run-windows-updates.sh "${{ steps.launch.outputs.instance_id }}"

- name: Install/update Chrome, Firefox, NVDA
id: software
Expand Down Expand Up @@ -148,13 +134,9 @@ jobs:
id: create-image
run: |
AMI_NAME="windows-a11y-${{ github.event.inputs.ami_name }}"
IMAGE_ID=$(aws ec2 create-image \
--instance-id "${{ steps.launch.outputs.instance_id }}" \
--name "${AMI_NAME}" \
--description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \
--query 'ImageId' --output text)
aws ec2 wait image-available --image-ids "${IMAGE_ID}"
echo "ami_id=${IMAGE_ID}" >> "$GITHUB_OUTPUT"
bash scripts/windows-a11y/create-ami.sh \
"${{ steps.launch.outputs.instance_id }}" \
"${AMI_NAME}"

- name: Tag AMI and snapshots
run: |
Expand Down
2 changes: 1 addition & 1 deletion docs/windows-a11y-aws-manual-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ reliable than using the localized base image.
- Source: `Custom` → enter your office/VPN CIDR block (e.g. `203.0.113.0/24`) — do **not** use `0.0.0.0/0`.
- Description: `Office VPN RDP access`
6. **Outbound rules**: leave the default (all traffic allowed) — the instance needs outbound HTTPS for
Windows Update, Chocolatey, and the SSM agent.
Windows Update, Google, Mozilla, NV Access, and the SSM agent.
7. **Tags**: `Name` = `windows-a11y-rdp`.
8. Click **Create security group**. Copy the resulting **Security group ID** (e.g. `sg-0123456789abcdef0`).
9. Record this value — it becomes the `SECURITY_GROUP_ID` GitHub variable in step 5.
Expand Down
71 changes: 71 additions & 0 deletions scripts/windows-a11y/create-ami.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail

INSTANCE_ID="${1:?Usage: create-ami.sh <instance-id> <ami-name>}"
AMI_NAME="${2:?Usage: create-ami.sh <instance-id> <ami-name>}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must point to the GitHub Actions output file}"

MAX_ATTEMPTS="${AMI_MAX_ATTEMPTS:-120}"
POLL_INTERVAL_SECONDS="${AMI_POLL_INTERVAL_SECONDS:-15}"
LOG_PREFIX="[create-ami]"

IMAGE_ID=$(aws ec2 create-image \
--instance-id "${INSTANCE_ID}" \
--name "${AMI_NAME}" \
--description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \
--query 'ImageId' \
--output text)

echo "${LOG_PREFIX} Created AMI ${IMAGE_ID}."
echo "ami_id=${IMAGE_ID}" >> "${GITHUB_OUTPUT}"

for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
set +e
IMAGE_JSON=$(aws ec2 describe-images --image-ids "${IMAGE_ID}" --output json 2>&1)
DESCRIBE_STATUS=$?
set -e

if (( DESCRIBE_STATUS != 0 )); then
if grep -q 'InvalidAMIID.NotFound' <<< "${IMAGE_JSON}"; then
echo "${LOG_PREFIX} AMI ${IMAGE_ID} is not visible yet (attempt ${attempt} of ${MAX_ATTEMPTS})."
if (( attempt < MAX_ATTEMPTS )); then
sleep "${POLL_INTERVAL_SECONDS}"
fi
continue
fi

echo "${IMAGE_JSON}" >&2
exit "${DESCRIBE_STATUS}"
fi

STATE=$(jq -r '.Images[0].State // "missing"' <<< "${IMAGE_JSON}")
STATE_REASON=$(jq -r '.Images[0].StateReason.Message // empty' <<< "${IMAGE_JSON}")

if [[ -n "${STATE_REASON}" ]]; then
echo "${LOG_PREFIX} AMI ${IMAGE_ID} state: ${STATE} (attempt ${attempt} of ${MAX_ATTEMPTS}); reason: ${STATE_REASON}."
else
echo "${LOG_PREFIX} AMI ${IMAGE_ID} state: ${STATE} (attempt ${attempt} of ${MAX_ATTEMPTS})."
fi

case "${STATE}" in
available)
exit 0
;;
failed)
echo "${LOG_PREFIX} AMI ${IMAGE_ID} creation failed." >&2
exit 1
;;
pending)
if (( attempt < MAX_ATTEMPTS )); then
sleep "${POLL_INTERVAL_SECONDS}"
fi
;;
*)
echo "${LOG_PREFIX} Unexpected AMI state '${STATE}' for ${IMAGE_ID}." >&2
exit 1
;;
esac
done

echo "${LOG_PREFIX} Timed out waiting for AMI ${IMAGE_ID} after ${MAX_ATTEMPTS} checks." >&2
exit 1
203 changes: 139 additions & 64 deletions scripts/windows-a11y/install-software.ps1
Original file line number Diff line number Diff line change
@@ -1,19 +1,110 @@
[CmdletBinding()]
param()
param([switch]$SkipExecution)

$ErrorActionPreference = 'Stop'
$logPrefix = '[install-software]'
$nvdaStableBaseUri = [Uri]'https://download.nvaccess.org/releases/stable/'

if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Output "$logPrefix Installing Chocolatey..."
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
$env:Path = "$env:Path;C:\ProgramData\chocolatey\bin"
function Get-FirefoxInstallerUri {
return [Uri]'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW'
}

$chocoPath = (Get-Command choco -ErrorAction Stop).Source
$successfulExitCodes = @(0, 2, 1641, 3010)
function Get-NvdaStableInstallerUri {
param(
[Parameter(Mandatory = $true)][string]$Content,
[Uri]$BaseUri = $nvdaStableBaseUri
)

$hrefPattern = 'href=["''](?<href>nvda_(?<version>\d{4}\.\d+(?:\.\d+)?)\.exe)["'']'
$matches = @([regex]::Matches($Content, $hrefPattern, 'IgnoreCase'))
if ($matches.Count -ne 1) {
throw "$logPrefix Expected exactly one numeric stable NVDA installer, found $($matches.Count)."
}
return [Uri]::new($BaseUri, $matches[0].Groups['href'].Value)
}

function Find-NvdaExecutable {
$candidates = @(
'C:\Program Files\NVDA\nvda.exe'
'C:\Program Files (x86)\NVDA\nvda.exe'
)

return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
}

function Invoke-WebRequestWithRetry {
param(
[Parameter(Mandatory = $true)][Uri]$Uri,
[string]$OutFile,
[string]$Operation = 'download',
[ValidateRange(1, 3)][int]$MaxAttempts = 3
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
try {
$parameters = @{ Uri = $Uri; UseBasicParsing = $true }
if ($OutFile) { $parameters.OutFile = $OutFile }
return Invoke-WebRequest @parameters
} catch {
if ($attempt -eq $MaxAttempts) {
throw "$logPrefix Download from $Uri failed after $MaxAttempts attempts: $($_.Exception.Message)"
}
$delaySeconds = 15 * $attempt
Write-Warning "$logPrefix $Operation attempt $attempt of $MaxAttempts failed; retrying in $delaySeconds seconds."
Start-Sleep -Seconds $delaySeconds
}
}
}

function Assert-AuthenticodePublisher {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$ProductName,
[Parameter(Mandatory = $true)][string]$PublisherPattern
)
$signature = Get-AuthenticodeSignature -FilePath $Path
$publisher = if ($signature.SignerCertificate) {
$signature.SignerCertificate.Subject
} else {
''
}
if ($signature.Status -ne 'Valid' -or $publisher -notmatch $PublisherPattern) {
throw "$logPrefix $ProductName signature verification failed (status: $($signature.Status), publisher: $publisher)"
}
}

function Assert-InstallerExitCode {
param([string]$ProductName, [int]$ExitCode)
if ($ExitCode -ne 0) {
throw "$logPrefix $ProductName installation failed with exit code $ExitCode."
}
}

function Install-Firefox {
$downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers'
$installerPath = Join-Path $downloadDirectory 'firefox-zh-TW-win64-latest.exe'
New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null
Invoke-WebRequestWithRetry -Uri (Get-FirefoxInstallerUri) -OutFile $installerPath `
-Operation 'Firefox installer download'
Assert-AuthenticodePublisher -Path $installerPath -ProductName 'Firefox' `
-PublisherPattern '(^|, )O=Mozilla Corporation(,|$)'
$process = Start-Process -FilePath $installerPath -ArgumentList @('/S') -Wait -PassThru
Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode $process.ExitCode
Remove-Item -LiteralPath $installerPath -Force
}

function Install-Nvda {
$downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers'
New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null
$listing = Invoke-WebRequestWithRetry -Uri $nvdaStableBaseUri -Operation 'NVDA stable listing download'
$installerUri = Get-NvdaStableInstallerUri -Content $listing.Content
$installerPath = Join-Path $downloadDirectory ([IO.Path]::GetFileName($installerUri.AbsolutePath))
Invoke-WebRequestWithRetry -Uri $installerUri -OutFile $installerPath -Operation 'NVDA installer download'
Assert-AuthenticodePublisher -Path $installerPath -ProductName 'NVDA' `
-PublisherPattern '(^|, )O=NV Access Limited(,|$)'
$process = Start-Process -FilePath $installerPath -ArgumentList @('--install-silent') -Wait -PassThru
Assert-InstallerExitCode -ProductName 'NVDA' -ExitCode $process.ExitCode
Remove-Item -LiteralPath $installerPath -Force
}

function Install-GoogleChrome {
$installerUri = 'https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi'
Expand All @@ -28,7 +119,7 @@ function Install-GoogleChrome {

# The Chrome Enterprise URL always points at the current stable MSI, so a
# static checksum would become stale. Verify Google's code-signing identity
# instead of bypassing integrity checks with Chocolatey's --ignore-checksums.
# rather than bypassing integrity checks.
$signature = Get-AuthenticodeSignature -FilePath $installerPath
$publisher = $signature.SignerCertificate.Subject
if ($signature.Status -ne 'Valid' -or $publisher -notmatch '(^|, )O=Google LLC(,|$)') {
Expand Down Expand Up @@ -56,36 +147,6 @@ function Install-GoogleChrome {
Remove-Item -Path $installerPath -Force
}

function Install-OrUpgradePackage {
param(
[Parameter(Mandatory = $true)]
[string]$Package,

[int]$MaxAttempts = 3
)

for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
Write-Output "$logPrefix Installing/updating $Package (attempt $attempt of $MaxAttempts)..."

# Do not discard Chocolatey's output: it contains the installer-specific
# error that is needed to diagnose failures from the SSM command log.
& $chocoPath upgrade $Package -y --no-progress --execution-timeout=1200 --ignore-detected-reboot
$exitCode = $LASTEXITCODE

if ($exitCode -in $successfulExitCodes) {
return
}

if ($attempt -lt $MaxAttempts) {
$retryDelay = 15 * $attempt
Write-Warning "$logPrefix choco upgrade $Package failed with exit code $exitCode; retrying in $retryDelay seconds."
Start-Sleep -Seconds $retryDelay
}
}

throw "$logPrefix choco upgrade $Package failed after $MaxAttempts attempts (last exit code: $exitCode)"
}

function Find-GoogleChromeExecutable {
$candidates = @(
'C:\Program Files\Google\Chrome\Application\chrome.exe'
Expand Down Expand Up @@ -124,32 +185,46 @@ function Wait-GoogleChromeExecutable {
return $null
}

$packages = @('firefox', 'nvda')
foreach ($package in $packages) {
Install-OrUpgradePackage -Package $package
}

Install-GoogleChrome

Write-Output "$logPrefix Installed package versions:"
$chromeExecutable = Wait-GoogleChromeExecutable
if (-not $chromeExecutable) {
$installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log'
$logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 -ErrorAction SilentlyContinue) -join [Environment]::NewLine
throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail"
function Write-InstalledSoftwareVersions {
param(
[Parameter(Mandatory = $true)]
[Collections.IDictionary]$SoftwareExecutables
)
foreach ($software in $SoftwareExecutables.GetEnumerator()) {
if (-not (Test-Path $software.Value)) {
throw "$logPrefix $($software.Key) executable was not found at $($software.Value)"
}
$versionInfo = (Get-Item $software.Value).VersionInfo
$version = if ($versionInfo.ProductVersion) {
$versionInfo.ProductVersion
} else {
$versionInfo.FileVersion
}
Write-Output "VERSION_$($software.Key)=$version"
}
}

$softwareExecutables = [ordered]@{
GOOGLECHROME = $chromeExecutable
FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe'
NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe'
}
foreach ($software in $softwareExecutables.GetEnumerator()) {
if (-not (Test-Path $software.Value)) {
throw "$logPrefix $($software.Key) executable was not found at $($software.Value)"
function Invoke-InstallSoftware {
Install-Firefox
Install-Nvda
Install-GoogleChrome

Write-Output "$logPrefix Installed package versions:"
$chromeExecutable = Wait-GoogleChromeExecutable
if (-not $chromeExecutable) {
$installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log'
$logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 `
-ErrorAction SilentlyContinue) -join [Environment]::NewLine
throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail"
}
$softwareExecutables = [ordered]@{
GOOGLECHROME = $chromeExecutable
FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe'
NVDA = Find-NvdaExecutable
}
Write-InstalledSoftwareVersions -SoftwareExecutables $softwareExecutables
}

$versionInfo = (Get-Item $software.Value).VersionInfo
$version = if ($versionInfo.ProductVersion) { $versionInfo.ProductVersion } else { $versionInfo.FileVersion }
Write-Output "VERSION_$($software.Key)=$version"
if (-not $SkipExecution) {
Invoke-InstallSoftware
}
Loading
Loading