diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34874dd8..84be9e52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,6 +98,9 @@ jobs: - os: windows-latest goos: windows goarch: amd64 + - os: windows-11-arm + goos: windows + goarch: arm64 runs-on: ${{ matrix.os }} steps: @@ -123,8 +126,8 @@ jobs: rm -rf internal/web/dist cp -r frontend/dist internal/web/dist - - name: Setup MinGW (Windows) - if: matrix.goos == 'windows' + - name: Setup MinGW (Windows amd64) + if: matrix.goos == 'windows' && matrix.goarch == 'amd64' uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2 with: msystem: MINGW64 @@ -132,6 +135,39 @@ jobs: install: mingw-w64-x86_64-gcc path-type: inherit + - name: Setup llvm-mingw (Windows arm64) + if: matrix.goos == 'windows' && matrix.goarch == 'arm64' + shell: pwsh + env: + # Self-contained aarch64 mingw clang toolchain (clang + runtime + + # lld). cgo (mattn/go-sqlite3) needs a C compiler; no Visual + # Studio / Windows SDK required. Pinned to a version + SHA256 for + # reproducibility and supply-chain integrity. + LLVM_MINGW_VERSION: "20260602" + LLVM_MINGW_NAME: llvm-mingw-20260602-ucrt-aarch64 + LLVM_MINGW_SHA256: cb5c20fbe1808e31ada5cbe4efd9daa2fee19c91dac6ec5ca1ac46a9c7247e37 + run: | + $ErrorActionPreference = 'Stop' + $zip = Join-Path $env:RUNNER_TEMP 'llvm-mingw.zip' + $url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$env:LLVM_MINGW_VERSION/$env:LLVM_MINGW_NAME.zip" + Write-Host "Downloading $url" + Invoke-WebRequest -Uri $url -OutFile $zip + # Verify the archive against the pinned SHA256 before extracting or + # executing any of it, so a swapped or compromised upstream asset + # fails the build instead of producing a trojaned binary. + $actual = (Get-FileHash -Path $zip -Algorithm SHA256).Hash + if ($actual -ne $env:LLVM_MINGW_SHA256) { + throw "Checksum mismatch for $url`n expected $env:LLVM_MINGW_SHA256`n actual $actual" + } + # 7-Zip is preinstalled on the runner image and extracts faster + # than Expand-Archive for this many files. + 7z x $zip -o"$env:RUNNER_TEMP" | Out-Null + $bin = Join-Path $env:RUNNER_TEMP "$env:LLVM_MINGW_NAME\bin" + $cc = Join-Path $bin 'aarch64-w64-mingw32-clang.exe' + if (-not (Test-Path $cc)) { throw "CC not found at $cc" } + Add-Content -Path $env:GITHUB_PATH -Value $bin + Add-Content -Path $env:GITHUB_ENV -Value "CC=$cc" + - name: Build shell: bash env: diff --git a/scripts/build_wheels.py b/scripts/build_wheels.py index e7ded8ae..1be3071e 100644 --- a/scripts/build_wheels.py +++ b/scripts/build_wheels.py @@ -43,6 +43,10 @@ "wheel_tag": "win_amd64", "binary_name": "agentsview.exe", }, + "windows_arm64": { + "wheel_tag": "win_arm64", + "binary_name": "agentsview.exe", + }, } _ARCHIVE_RE = re.compile( diff --git a/scripts/build_wheels_test.py b/scripts/build_wheels_test.py index ad9db4da..6fc0e3db 100644 --- a/scripts/build_wheels_test.py +++ b/scripts/build_wheels_test.py @@ -31,6 +31,7 @@ def test_all_required_platforms_present(self) -> None: "darwin_amd64", "darwin_arm64", "windows_amd64", + "windows_arm64", } assert set(PLATFORM_MAP.keys()) == required @@ -48,6 +49,7 @@ def test_each_entry_has_binary_name(self) -> None: def test_windows_binary_has_exe_extension(self) -> None: assert PLATFORM_MAP["windows_amd64"]["binary_name"] == "agentsview.exe" + assert PLATFORM_MAP["windows_arm64"]["binary_name"] == "agentsview.exe" def test_unix_binaries_have_no_extension(self) -> None: for key in ("linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"): @@ -63,6 +65,7 @@ def test_macos_wheel_tags(self) -> None: def test_windows_wheel_tag(self) -> None: assert PLATFORM_MAP["windows_amd64"]["wheel_tag"] == "win_amd64" + assert PLATFORM_MAP["windows_arm64"]["wheel_tag"] == "win_arm64" # --------------------------------------------------------------------------- @@ -83,6 +86,10 @@ def test_parse_windows_amd64_zip(self) -> None: result = parse_archive_filename("agentsview_0.15.0_windows_amd64.zip") assert result == ("windows_amd64", "0.15.0") + def test_parse_windows_arm64_zip(self) -> None: + result = parse_archive_filename("agentsview_0.15.0_windows_arm64.zip") + assert result == ("windows_arm64", "0.15.0") + def test_parse_darwin_amd64_tar_gz(self) -> None: result = parse_archive_filename("agentsview_2.0.0_darwin_amd64.tar.gz") assert result == ("darwin_amd64", "2.0.0") @@ -303,13 +310,14 @@ def test_wheel_filename_darwin_arm64(self, tmp_path: Path) -> None: class TestBuildAllWheels: def _make_fake_archives(self, input_dir: Path, version: str) -> None: - """Create fake release archives for all 5 platforms.""" + """Create fake release archives for every supported platform.""" platforms = [ ("linux_amd64", "agentsview", ".tar.gz"), ("linux_arm64", "agentsview", ".tar.gz"), ("darwin_amd64", "agentsview", ".tar.gz"), ("darwin_arm64", "agentsview", ".tar.gz"), ("windows_amd64", "agentsview.exe", ".zip"), + ("windows_arm64", "agentsview.exe", ".zip"), ] for platform_key, binary_name, ext in platforms: content = f"binary-for-{platform_key}".encode() @@ -322,13 +330,13 @@ def _make_fake_archives(self, input_dir: Path, version: str) -> None: # Also add a SHA256SUMS file that should be skipped (input_dir / f"agentsview_{version}_SHA256SUMS").write_text("checksums here") - def test_produces_five_wheels(self, tmp_path: Path) -> None: + def test_produces_a_wheel_per_platform(self, tmp_path: Path) -> None: input_dir = tmp_path / "input" output_dir = tmp_path / "output" input_dir.mkdir() self._make_fake_archives(input_dir, "0.15.0") wheels = build_all_wheels(input_dir, output_dir, "0.15.0") - assert len(wheels) == 5 + assert len(wheels) == len(PLATFORM_MAP) def test_correct_wheel_names(self, tmp_path: Path) -> None: input_dir = tmp_path / "input" @@ -343,6 +351,7 @@ def test_correct_wheel_names(self, tmp_path: Path) -> None: "agentsview-0.15.0-py3-none-macosx_11_0_x86_64.whl", "agentsview-0.15.0-py3-none-macosx_11_0_arm64.whl", "agentsview-0.15.0-py3-none-win_amd64.whl", + "agentsview-0.15.0-py3-none-win_arm64.whl", } assert names == expected @@ -355,7 +364,7 @@ def test_unknown_platforms_skipped(self, tmp_path: Path) -> None: unknown = input_dir / "agentsview_0.15.0_freebsd_amd64.tar.gz" unknown.write_bytes(_make_targz("agentsview", b"fake")) wheels = build_all_wheels(input_dir, output_dir, "0.15.0") - assert len(wheels) == 5 # still only 5 + assert len(wheels) == len(PLATFORM_MAP) # unknown skipped def test_output_dir_created_if_missing(self, tmp_path: Path) -> None: input_dir = tmp_path / "input" @@ -403,4 +412,4 @@ def test_require_all_passes_with_all_platforms(self, tmp_path: Path) -> None: wheels = build_all_wheels( input_dir, output_dir, "1.0.0", require_all=True ) - assert len(wheels) == 5 + assert len(wheels) == len(PLATFORM_MAP)