Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,77 @@ jobs:
if: ${{ steps.coverage.outputs.value }}
with:
file: build/coverage.info

# Supplemental native Arm64 presubmit. Its output is never a release
# artifact; GitLab remains the production artifact chain.
build-arm64-presubmit:
runs-on: windows-11-arm
continue-on-error: true
steps:
- name: Preserve repository line endings
run: git config --global core.autocrlf input

- uses: actions/checkout@v4

- name: Checkout OpenShotAudio
uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.owner.login || github.repository_owner }}/libopenshot-audio
ref: ${{ github.event.pull_request.head.ref || github.ref_name }}
path: audio

- name: Checkout Catch2
uses: actions/checkout@v4
with:
repository: catchorg/Catch2
ref: v3.8.1
path: Catch2

- uses: msys2/setup-msys2@v2
with:
msystem: CLANGARM64
update: true

- name: Install exact CLANGARM64 package versions
shell: msys2 {0}
run: |
mapfile -t packages < <(sed -n '/^[^#[:space:]][^=]*=/s/,[^,]*$//p' ci/windows-arm64-packages.lock)
pacman --noconfirm -S --needed -- "${packages[@]}"

- name: Build (CLANGARM64, presubmit only)
shell: msys2 {0}
run: |
cmake -B Catch2/build -S Catch2 -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PWD/catch2-install" \
-DCATCH_BUILD_TESTING=OFF \
-DCATCH_INSTALL_DOCS=OFF
cmake --build Catch2/build
cmake --install Catch2/build
cmake -B audio/build -S audio -G Ninja \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_INSTALL_PREFIX="$PWD/audio/install-arm64" \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_AUDIO_DOCS=OFF
cmake --build audio/build
cmake --install audio/build
cmake -B build -S . -G Ninja \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DUSE_QT6=ON \
-DOpenShotAudio_ROOT="$PWD/audio/install-arm64" \
-DCatch2_DIR="$PWD/catch2-install/lib/cmake/Catch2" \
-DCMAKE_INSTALL_PREFIX="$PWD/install-arm64" \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_LIB_DOCS=OFF
cmake --build build
ctest --test-dir build --output-on-failure -VV
cmake --install build
python -m unittest discover -s ci -p "test_*.py" -v
python ci/validate_arm64_architecture.py \
--require-native-arm64 \
--package-lock ci/windows-arm64-packages.lock \
--payload-root install-arm64 \
--require-payload \
--json-report build/arm64-presubmit-report.json
36 changes: 36 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,42 @@ windows-builder-x64:
tags:
- windows

windows-builder-arm64:
stage: build-libopenshot
artifacts:
expire_in: 6 months
paths:
- build\install-arm64\*
- build\arm64-architecture-report.json
script:
- try { Invoke-WebRequest -Uri "https://gitlab.openshot.org/OpenShot/libopenshot-audio/-/jobs/artifacts/$CI_COMMIT_REF_NAME/download?job=windows-builder-arm64" -Headers @{"PRIVATE-TOKEN"="$ACCESS_TOKEN"} -OutFile "artifacts.zip" } catch { $_.Exception.Response.StatusCode.Value__ }
- if (-not (Test-Path "artifacts.zip")) { Invoke-WebRequest -Uri "https://gitlab.openshot.org/OpenShot/libopenshot-audio/-/jobs/artifacts/develop/download?job=windows-builder-arm64" -Headers @{"PRIVATE-TOKEN"="$ACCESS_TOKEN"} -OutFile "artifacts.zip" }
- Expand-Archive -Path artifacts.zip -DestinationPath .
- $env:MSYSTEM = "CLANGARM64"
- $env:Path = "C:\msys64\clangarm64\bin;C:\msys64\usr\bin;" + $env:Path;
- cmake -B build -S . -DCMAKE_VERBOSE_MAKEFILE:BOOL=ON -D"CMAKE_C_COMPILER=clang" -D"CMAKE_CXX_COMPILER=clang++" -D"CMAKE_INSTALL_PREFIX:PATH=$CI_PROJECT_DIR\build\install-arm64" -D"OpenShotAudio_ROOT=$CI_PROJECT_DIR\build\install-arm64" -D"PYTHON_MODULE_PATH=python" -D"USE_QT6=ON" -D"OPENSHOT_QT_API=pyqt6" -G Ninja -D"CMAKE_BUILD_TYPE:STRING=Release"
- cmake --build build --parallel $([Environment]::ProcessorCount)
- ctest --test-dir build --output-on-failure -VV
- cmake --install build
- python -m unittest discover -s ci -p "test_*.py" -v
- python ci\validate_arm64_architecture.py --require-native-arm64 --package-lock ci\windows-arm64-packages.lock --payload-root build\install-arm64 --require-payload --json-report build\arm64-architecture-report.json
- $PROJECT_VERSION = (Select-String -Path "CMakeLists.txt" -Pattern '^set\(PROJECT_VERSION_FULL "(.*)\"' | %{$_.Matches.Groups[1].value})
- $PROJECT_SO = (Select-String -Path "CMakeLists.txt" -Pattern '^set\(PROJECT_SO_VERSION (.*)\)' | %{$_.Matches.Groups[1].value})
- New-Item -path "build/install-arm64/share/" -Name "$CI_PROJECT_NAME.env" -Value "CI_PROJECT_NAME:$CI_PROJECT_NAME`nCI_COMMIT_REF_NAME:$CI_COMMIT_REF_NAME`nCI_COMMIT_SHA:$CI_COMMIT_SHA`nCI_JOB_ID:$CI_JOB_ID`nCI_PIPELINE_ID:$CI_PIPELINE_ID`nVERSION:$PROJECT_VERSION`nSO:$PROJECT_SO`nTARGET_TRIPLET:aarch64-w64-mingw32`nPE_MACHINE:0xAA64" -ItemType file -force
- $PREV_GIT_LABEL=(git describe --tags --abbrev=0 '@^')
- git log "$PREV_GIT_LABEL..@" --oneline --pretty=format:"- %C(auto,yellow)%h%C(auto,magenta)% %C(auto,blue)%>(12,trunc)%ad %C(auto,green)%<(25,trunc)%aN%C(auto,reset)%s%C(auto,red)% gD% D" --date=short > "build/install-arm64/share/$CI_PROJECT_NAME.log"
when: always
rules:
- if: '$ENABLE_WINDOWS_ARM64 == "1" && $CI_COMMIT_TAG == null'
- when: never
tags:
- windows-arm64
# Requires PR A's published windows-builder-arm64 artifact/digest and a
# native/virtual Windows Arm64 GitLab runner (design-spec.md
# release-infrastructure surface). Does not weaken or replace the
# existing windows-builder-x64/x86 jobs above.
allow_failure: true

windows-builder-x86:
stage: build-libopenshot
artifacts:
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ if(DEFINED UNIT_TEST_TARGETS AND NOT TARGET coverage)
DEPENDS openshot openshot-${_t}-test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMENT "Running unit tests for ${_t} class (coverage disabled)"
VERBATIM
)
endforeach()
endif()
Expand Down
155 changes: 155 additions & 0 deletions ci/test_validate_arm64_architecture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# SPDX-FileCopyrightText: 2026 OpenShot Studios, LLC
# SPDX-License-Identifier: LGPL-3.0-or-later

import contextlib
import importlib.util
import io
import os
import struct
import sys
import tempfile
import unittest
from unittest import mock

VALIDATOR_PATH = os.path.join(os.path.dirname(__file__), "validate_arm64_architecture.py")
SPEC = importlib.util.spec_from_file_location("validate_arm64_architecture", VALIDATOR_PATH)
if SPEC is None or SPEC.loader is None:
raise RuntimeError("Unable to load validator from %s" % VALIDATOR_PATH)
validator = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(validator)


def write_pe(path, machine):
data = bytearray(0x80)
data[:2] = b"MZ"
struct.pack_into("<I", data, 0x3C, 0x40)
data[0x40:0x44] = b"PE\0\0"
struct.pack_into("<H", data, 0x44, machine)
with open(path, "wb") as stream:
stream.write(data)


class Arm64ArchitectureValidatorTests(unittest.TestCase):
def test_package_lock_reports_missing_file(self):
verified, failures = validator.verify_package_lock("missing-package-lock.txt")
self.assertEqual(verified, [])
self.assertIn("Unable to read package lock", failures[0])

def test_payload_scan_accepts_arm64_and_rejects_amd64(self):
with tempfile.TemporaryDirectory() as root:
write_pe(os.path.join(root, "native.dll"), validator.IMAGE_FILE_MACHINE_ARM64)
results, failures = validator.scan_payload_architecture(root)
self.assertEqual(len(results), 1)
self.assertEqual(failures, [])

write_pe(os.path.join(root, "foreign.pyd"), validator.IMAGE_FILE_MACHINE_AMD64)
results, failures = validator.scan_payload_architecture(root)
self.assertEqual(len(results), 2)
self.assertEqual(len(failures), 1)

def test_required_native_host_fails_closed(self):
oracle = {
"checked": True,
"process_machine": validator.IMAGE_FILE_MACHINE_UNKNOWN,
"native_machine": validator.IMAGE_FILE_MACHINE_AMD64,
"is_wow_or_emulated": False,
"native_arm64_ok": False,
"reason": None,
}
with mock.patch.object(validator, "read_native_process_oracle", return_value=oracle):
with mock.patch.object(sys, "argv", ["validator", "--require-native-arm64"]):
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(validator.main(), 1)

def test_package_lock_detects_version_drift(self):
with tempfile.NamedTemporaryFile("w", delete=False) as lock:
lock.write("example-package=1.2.3,UNVERIFIED-NO-SIGNED-SNAPSHOT\n")
lock_path = lock.name
try:
completed = mock.Mock(returncode=0, stdout="example-package 1.2.4\n")
with mock.patch.object(validator.subprocess, "run", return_value=completed):
verified, failures = validator.verify_package_lock(lock_path)
self.assertEqual(verified[0]["version"], "1.2.4")
self.assertEqual(len(failures), 1)
finally:
os.unlink(lock_path)

def test_package_lock_reports_missing_pacman(self):
with tempfile.NamedTemporaryFile("w", delete=False) as lock:
lock.write("example-package=1.2.3,UNVERIFIED-NO-SIGNED-SNAPSHOT\n")
lock_path = lock.name
try:
with mock.patch.object(
validator.subprocess, "run", side_effect=FileNotFoundError("pacman")
):
verified, failures = validator.verify_package_lock(lock_path)
self.assertEqual(verified, [])
self.assertIn("Unable to run pacman", failures[0])
finally:
os.unlink(lock_path)

def test_package_lock_reports_malformed_entry(self):
with tempfile.NamedTemporaryFile("w", delete=False) as lock:
lock.write("malformed-entry\n")
lock_path = lock.name
try:
verified, failures = validator.verify_package_lock(lock_path)
self.assertEqual(verified, [])
self.assertEqual(failures, ["Malformed package lock entry: malformed-entry"])
finally:
os.unlink(lock_path)

def test_package_lock_rejects_unsafe_package_name(self):
with tempfile.NamedTemporaryFile("w", delete=False) as lock:
lock.write("-unsafe=1.2.3,UNVERIFIED-NO-SIGNED-SNAPSHOT\n")
lock_path = lock.name
try:
verified, failures = validator.verify_package_lock(lock_path)
self.assertEqual(verified, [])
self.assertEqual(failures, ["Invalid package name in lock: '-unsafe'"])
finally:
os.unlink(lock_path)

def test_package_lock_rejects_unexpected_pacman_output(self):
with tempfile.NamedTemporaryFile("w", delete=False) as lock:
lock.write("example-package=1.2.3,UNVERIFIED-NO-SIGNED-SNAPSHOT\n")
lock_path = lock.name
try:
completed = mock.Mock(returncode=0, stdout="warning only\n")
with mock.patch.object(validator.subprocess, "run", return_value=completed):
verified, failures = validator.verify_package_lock(lock_path)
self.assertEqual(verified, [])
self.assertIn("Unexpected pacman output", failures[0])
finally:
os.unlink(lock_path)

def test_package_lock_rejects_unverified_real_hash(self):
with tempfile.NamedTemporaryFile("w", delete=False) as lock:
lock.write("example-package=1.2.3,abc123\n")
lock_path = lock.name
try:
completed = mock.Mock(returncode=0, stdout="example-package 1.2.3\n")
with mock.patch.object(validator.subprocess, "run", return_value=completed):
_verified, failures = validator.verify_package_lock(lock_path)
self.assertIn("hash verification is not implemented", failures[0])
finally:
os.unlink(lock_path)

def test_require_payload_rejects_missing_root(self):
with mock.patch.object(sys, "argv", ["validator", "--require-payload"]):
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(validator.main(), 1)

def test_require_payload_rejects_directory_without_valid_pe(self):
with tempfile.TemporaryDirectory() as root:
with open(os.path.join(root, "stub.exe"), "w", encoding="utf-8") as stream:
stream.write("not a PE")
with mock.patch.object(
sys, "argv", ["validator", "--payload-root", root, "--require-payload"]
):
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(validator.main(), 1)


if __name__ == "__main__":
unittest.main()
Loading