Testing x86_64 and AArch64 GitHub Actions Locally with act
Introduction
Testing a GitHub Actions workflow or an application for several distributions frequently involves an inefficient development loop:
- Modify the workflow or application.
- Commit the change.
- Push it to GitHub.
- Wait for a runner.
- Consume unnecessary GitHub compute.
- Discover a missing package, an incorrect artifact path, or a shell error.
- Repeat.
This is manageable for a small workflow. It becomes much more frustrating when the workflow builds a large C++ application, generates Debian packages and AppImages, and targets both x86_64 and AArch64.
I encountered this problem while developing multi-architecture packaging workflows for scientific software. I wanted to test the same GitHub Actions workflows locally before pushing them, including:
- x86_64 and AArch64 matrix entries
- Debian package construction and installation
- AppImage construction and execution
- Architecture-specific dependencies
- Artifact naming and upload paths
- ARM64 execution through QEMU on an x86_64 host
The natural solution is act, which reads GitHub Actions workflow files and executes their jobs locally using Docker containers. It reproduces much of the GitHub Actions environment, but it does not reproduce GitHub-hosted runners exactly. In particular, GitHub normally runs Linux jobs in managed virtual environments, whereas act normally uses Docker containers. Its default runner images may be incomplete, and some GitHub Actions functionality may be unsupported or behave differently.
Multi-architecture workflows add another level of complexity:
- GitHub, Linux, Debian, Docker, and compiler toolchains use different names for the same architectures.
- An AArch64 matrix entry does not automatically create an ARM64 Docker container.
- An x86_64 host needs QEMU and binfmt_misc to execute an ARM64 container.
- act must generally be invoked separately for each container architecture.
- AppImages may require extract-and-run mode because FUSE is unavailable in containers.
- ARM AppImages might fail under QEMU because of three AppImage-specific bytes inside their ELF header.
This article presents the complete setup that worked for me, including the AppImage workaround that took the longest to identify.
The architecture-name problem
Before configuring the workflow, it is important to understand why the same architecture appears under several names.
The following values normally refer to the same two processor architecture families:
x86_64 ≈ amd64
aarch64 ≈ arm64
They are not different architectures. They are conventions inherited from different projects and ecosystems.
| Context | 64-bit x86 | 64-bit Arm |
|---|---|---|
| Linux uname -m | x86_64 | aarch64 |
| GNU target triplet | x86_64-linux-gnu | aarch64-linux-gnu |
| Debian architecture | amd64 | arm64 |
| Docker/OCI platform | linux/amd64 | linux/arm64 |
| GitHub runner architecture | X64 | ARM64 |
| Project matrix value used here | x86_64 | aarch64 |
Why x86_64 and amd64 both exist
AMD originally designed the 64-bit extension of the x86 instruction set. It was therefore called AMD64.
Intel later implemented the same general architecture under the name Intel 64. Consequently, an amd64 Linux package is not restricted to AMD processors. It also runs on compatible Intel processors.
Debian retained amd64 as its package-architecture identifier. Debian explicitly states that its amd64 port supports both AMD processors with AMD64 extensions and Intel processors with Intel 64 extensions.
Linux and GNU tooling commonly use the more vendor-neutral spelling:
x86_64
Therefore, all of these normally describe the same 64-bit x86 target:
x86_64
amd64
linux/amd64
x86_64-linux-gnu
The selected name depends on whether the tool describes a kernel machine type, a Debian package architecture, a compiler target, or a container platform.
Why aarch64 and arm64 both exist
Arm defines AArch64 as the 64-bit execution state of the Arm architecture. AArch64 uses the A64 instruction set and 64-bit registers.
Linux and GNU tools commonly expose this name in lowercase:
aarch64
For example:
uname -m
normally returns:
aarch64
A GNU cross-compiler may be named:
aarch64-linux-gnu-g++
Debian uses the shorter architecture identifier:
arm64
Docker and OCI also use arm64 in platform descriptions:
linux/arm64
Debian describes arm64 as its port for the 64-bit Arm architecture (AArch64), while the OCI image specification uses arm64 as the architecture name for 64-bit Arm container images.
Therefore, these values normally refer to the same target:
aarch64
arm64
linux/arm64
aarch64-linux-gnu
Why the conventions were never unified
The names come from systems that were developed for different purposes:
- ARM defines processor execution states such as AArch64.
- The Linux kernel exposes machine names such as x86_64 and aarch64.
- GNU toolchains encode target architectures in compiler triplets.
- Debian defines package architecture identifiers such as amd64 and arm64.
- Docker and OCI define operating-system/architecture pairs.
- GitHub defines runner labels and runner architecture values.
- Individual projects are free to choose their own matrix values.
No single naming authority controls all these systems.
The safest approach is therefore not to search for one universal name. Instead, choose one internal convention and map it explicitly to every external convention.
In my workflows, I use Linux-style values as the project-level architecture names:
matrix:
include:
- arch: x86_64
deb_arch: amd64
docker_platform: linux/amd64
- arch: aarch64
deb_arch: arm64
docker_platform: linux/arm64
This makes every conversion visible.
Define a real multi-architecture GitHub Actions matrix
The local test should run the same workflow GitHub runs. Maintaining a second simplified workflow specifically for act would allow the local and remote configurations to diverge.
A simplified AppImage test workflow looks like this:
name: Test AppImages on Ubuntu
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
jobs:
test:
name: Ubuntu / ${{ matrix.arch }}
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
deb_arch: amd64
runner: ubuntu-24.04
- arch: aarch64
deb_arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
env:
DEBIAN_FRONTEND: noninteractive
APPIMAGE_EXTRACT_AND_RUN: 1
ARCH: ${{ matrix.arch }}
steps:
- uses: actions/checkout@v4
- name: Verify architecture
shell: bash
run: |
set -euo pipefail
echo "Expected project architecture: ${{ matrix.arch }}"
echo "Detected kernel architecture: $(uname -m)"
echo "Detected Debian architecture: $(dpkg --print-architecture)"
test "$(uname -m)" = "${{ matrix.arch }}"
test "$(dpkg --print-architecture)" = "${{ matrix.deb_arch }}"
- name: Install test dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y \
file \
binutils \
squashfs-tools
- name: Run package tests
shell: bash
run: |
./test_app-${{ matrix.arch }}".AppImage
GitHub currently exposes ubuntu-24.04 for x64 jobs and ubuntu-24.04-arm for public ARM64 Ubuntu runners.
The architecture checking step is essential.
Without it, the matrix can say:
aarch64
while the actual container is still:
x86_64
A matrix variable is only a string. It does not reconfigure Docker by itself.
A failing check such as this is therefore useful:
Expected project architecture: aarch64
Detected kernel architecture: x86_64
Detected Debian architecture: amd64
It immediately proves that the workflow selected the ARM matrix entry, but act started an x86 container.
Understand what act reproduces
act reads workflow definitions, determines which actions and jobs should run, and uses the Docker API to execute them in containers. It configures much of the filesystem and environment to resemble GitHub Actions.
However, it is not a complete local implementation of GitHub-hosted runners.
The default runner images may omit tools that GitHub preinstalls. Container behavior can also differ from that of virtual machines. For example, services that depend on a fully booted init system may not work normally inside a Docker container. The act project also documents unsupported or partially supported features of GitHub Actions.
Install QEMU support on an x86_64 host
An x86_64 Linux host cannot directly execute an AArch64 userspace.
To run an ARM64 Docker container, the host needs a QEMU user-mode interpreter registered through Linux’s binfmt_misc mechanism.
A convenient setup command is:
docker run \
--privileged \
--rm \
tonistiigi/binfmt \
--install arm64
Docker documents the same mechanism for multi-platform execution: QEMU interpreters are installed and registered with binfmt_misc, allowing the kernel to dispatch non-native executables through QEMU.
Before involving act, verify the Docker and QEMU setup independently:
docker run \
--rm \
--platform linux/arm64 \
ubuntu:24.04 \
uname -m
The expected output is:
aarch64
You can also verify the Debian architecture:
docker run \
--rm \
--platform linux/arm64 \
ubuntu:24.04 \
dpkg --print-architecture
The expected output is:
arm64
If this basic Docker test fails, the problem is below the act layer.
Typical low-level failures include:
exec format error
or a container that stops before the first workflow step starts.
QEMU user-mode emulation is also slower than native execution. Docker recommends native nodes or cross-compilation when performance is important. For local correctness testing, however, QEMU is often sufficient.
Map GitHub runner labels to Docker images
A GitHub runner label is not a Docker image name.
For example:
runs-on: ubuntu-24.04-arm
instructs GitHub to select a hosted runner with that label. Docker does not know what ubuntu-24.04-arm means.
act maps runner labels to container images with the -P option:
act -P <runner-label>=<docker-image>
This mapping mechanism is documented by act.
In my local setup, I use:
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04
-P ubuntu-24.04-arm=catthehacker/ubuntu:act-24.04
Both labels map to the same image tag. The actual image architecture is selected independently through:
--container-architecture
A complete mapping set is therefore:
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
-P ubuntu-24.04-arm=catthehacker/ubuntu:act-24.04
This distinction is important:
-P
answers:
Which Docker image should represent this GitHub runner label?
while:
--container-architecture
answers:
For which CPU architecture should Docker run that image?
They solve different problems.
Run x86_64 and AArch64 in separate act invocations
GitHub creates a separate runner for each matrix entry. One matrix job can run on x64 while another runs on ARM64.
Locally, --container-architecture applies to the act invocation. The reliable approach is therefore to run each architecture separately.
For x86_64:
act workflow_dispatch \
-W .github/workflows/test_appimage.yml \
--matrix arch:x86_64 \
--container-architecture linux/amd64
For AArch64:
act workflow_dispatch \
-W .github/workflows/test_appimage.yml \
--matrix arch:aarch64 \
--container-architecture linux/arm64
act supports selecting workflow files with -W, setting a default container architecture, and filtering matrix configurations with one or more --matrix arguments.
I wrap this logic in a function:
run_act_arch() {
local workflow="$1"
local arch="$2"
local platform="$3"
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Workflow: ${workflow}"
echo "Architecture: ${arch}"
echo "Container platform: ${platform}"
echo "Matrix filter: arch:${arch}"
echo "Artifact directory: ${ARTIFACT_DIR}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
act workflow_dispatch \
-W ".github/workflows/${workflow}" \
--matrix "arch:${arch}" \
--container-architecture "${platform}" \
--artifact-server-path "${ARTIFACT_DIR}" \
--rm \
--pull \
-P ubuntu-latest=catthehacker/ubuntu:act-latest \
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
-P ubuntu-24.04-arm=catthehacker/ubuntu:act-24.04
}
It can then be called twice:
run_act_arch \
test_appimage.yml \
x86_64 \
linux/amd64
run_act_arch \
test_appimage.yml \
aarch64 \
linux/arm64
Filtering multiple matrix dimensions
Suppose the workflow contains both an architecture and a distribution version:
strategy:
matrix:
include:
- arch: x86_64
os: ubuntu-22.04
- arch: x86_64
os: ubuntu-24.04
- arch: aarch64
os: ubuntu-24.04
This command:
act --matrix arch:x86_64
selects both x86_64 configurations.
To select one exact combination, provide both filters:
act \
--matrix arch:x86_64 \
--matrix os:ubuntu-24.04
Validate the architecture at multiple levels
No single check is sufficient. I validate the architecture at the kernel, package manager, ELF, and package metadata levels.
1. Kernel architecture
uname -m
Expected values:
x86_64
or:
aarch64
2. Debian architecture
dpkg --print-architecture
Expected values:
amd64
or:
arm64
3. ELF architecture
For a binary:
file ./my-program
or:
readelf -h ./my-program | grep Machine
AArch64 output should identify the machine as AArch64.
4. Debian package metadata
dpkg-deb \
--field ./my-package.deb \
Architecture
Expected output:
amd64
or:
arm64
5. Binaries inside the package
Package metadata alone is not proof that every bundled binary has the correct architecture.
Extract the package:
rm -rf package-root
dpkg-deb \
--extract ./my-package.deb \
package-root
Then inspect its executables:
find package-root \
-type f \
-exec file {} + |
grep -E 'ELF.*(x86-64|aarch64)'
This catches packages labeled arm64 that accidentally contain a copied x86_64 executable.
6. Runtime validation
Finally, install and execute the package:
sudo apt-get install -y ./my-package.deb
my-command --version
Inspection proves what a file contains, and execution proves that the environment can actually load it.
AppImage problem one: FUSE inside containers
A type-2 AppImage contains a small executable runtime followed by an embedded filesystem.
During normal execution, the runtime mounts the embedded filesystem through FUSE and launches its AppRun entry point. The AppImage project describes the runtime as the executable component that mounts the payload and executes the contained application.
FUSE is often unavailable or restricted inside Docker containers.
The AppImage runtime provides an extract-and-run mode for this situation:
export APPIMAGE_EXTRACT_AND_RUN=1
./MyApplication.AppImage --version
The equivalent command-line option is:
./MyApplication.AppImage \
--appimage-extract-and-run \
--version
In this mode, the runtime extracts the embedded filesystem into a temporary directory, runs the application, waits for it to finish, and removes the extracted files. AppImage documents both the environment variable and the command-line option, noting that extraction is relatively expensive, that the folder is unique,and that it can pose a problem if the AppImage is executed concurrently from several threads.
For CI smoke tests, that performance cost is generally acceptable.
Older AppImages may not support extract-and-run mode. They can be extracted manually:
./MyApplication.AppImage --appimage-extract
This creates:
squashfs-root/
The entry point can then be executed directly:
./squashfs-root/AppRun --version
Under QEMU user-mode emulation, the kernel may reject the AppImage before its runtime even starts.
AppImage problem two: the AI\x02 magic bytes
A type-2 AppImage must simultaneously be:
- An executable ELF file.
- An AppImage runtime.
- A container for an embedded filesystem.
To identify the AppImage format, a type-2 AppImage stores the three-byte sequence:
AI\x02
Its hexadecimal representation is:
41 49 02
These bytes are located at offsets 8–10 of the ELF identification area.
Native Linux execution usually tolerates this AppImage-specific modification. QEMU user-mode execution through binfmt_misc may not.
The resulting behavior is confusing:
docker run \
--rm \
--platform linux/arm64 \
ubuntu:24.04 \
uname -m
works and prints:
aarch64
Ordinary ARM64 ELF executables also work.
However, an ARM64 AppImage may fail immediately:
cannot execute binary file: Exec format error
The failure occurs before the AppImage runtime gets a chance to process:
APPIMAGE_EXTRACT_AND_RUN=1
The AppImage project has an open issue describing this interaction. The issue identifies the AppImage magic bytes in the ELF header as a source of incompatibility with software such as QEMU and proposes clearing three bytes at offset 8 as a temporary workaround.
This means there are two separate AppImage/container problems:
| Failure stage | Cause | Workaround |
|---|---|---|
| Before the runtime starts | QEMU rejects the modified ELF identification | Temporarily clear the three magic bytes |
| After the runtime starts | FUSE is unavailable in the container | Enable extract-and-run mode |
Depending on the environment, both workarounds may be required.
The complete AppImage magic-byte workaround
The following function reads exactly three bytes beginning at offset 8:
read_appimage_magic() {
local appimage="$1"
dd \
if="${appimage}" \
bs=1 \
skip=8 \
count=3 \
status=none |
od -An -tx1 |
tr -d ' \n'
}
For a normal type-2 AppImage, the output should be:
414902
A complete patching function is:
patch_appimage_for_qemu() {
local appimage="$1"
local magic
if [[ ! -f "${appimage}" ]]; then
echo "AppImage not found: ${appimage}" >&2
return 1
fi
# AppImage stores AI\x02 at ELF offsets 8–10. QEMU user-mode
# execution through binfmt_misc may reject this modified ELF
# identification before the AppImage runtime starts.
magic="$(
dd \
if="${appimage}" \
bs=1 \
skip=8 \
count=3 \
status=none |
od -An -tx1 |
tr -d ' \n'
)"
case "${magic}" in
414902)
echo "Patching AppImage magic bytes for QEMU: ${appimage}"
dd \
if=/dev/zero \
of="${appimage}" \
bs=1 \
seek=8 \
count=3 \
conv=notrunc \
status=none
;;
000000)
echo "AppImage is already patched: ${appimage}"
;;
*)
echo \
"No type-2 AppImage magic found at offsets 8–10 " \
"in ${appimage}; detected ${magic:-<empty>}" \
>&2
return 1
;;
esac
}
The important dd options are:
bs=1
Operate on individual bytes.
seek=8
Begin writing at offset 8.
count=3
Replace exactly three bytes.
conv=notrunc
Do not truncate the rest of the AppImage.
After patching, the bytes can be verified:
magic="$(
dd \
if="${appimage}" \
bs=1 \
skip=8 \
count=3 \
status=none |
od -An -tx1 |
tr -d ' \n'
)"
test "${magic}" = "000000"
Apply the patch only to temporary local copies
Clearing the magic bytes changes the file’s AppImage identification.
The workaround must therefore not be applied to the final release artifact without being reversed. Use it only on temporary copies that need to be executed through QEMU.
A restrictive wrapper can use the environment variable that act automatically exposes during local runs:
maybe_patch_appimage_for_act() {
local appimage="$1"
if [[ "${ACT:-}" != "true" ]]; then
return 0
fi
if [[ "${ARCH:-}" != "aarch64" ]]; then
return 0
fi
patch_appimage_for_qemu "${appimage}"
}
act documents the special ACT environment variable for identifying local execution and conditionally skipping or altering steps.
The workflow already defines:
env:
ARCH: ${{ matrix.arch }}
The condition:
[[ "${ACT:-}" == "true" && "${ARCH:-}" == "aarch64" ]]
Therefore, it limits the modification to the local AArch64 job.
This is still a project convention rather than a universal emulation detector. It assumes that the local AArch64 act job is the one running through QEMU. That is true in my x86_64-hosted setup.
Keep artifact names architecture-specific
When multiple architectures are built, generic output names create avoidable ambiguity.
Avoid:
MyApplication.AppImage
Prefer:
MyApplication-x86_64.AppImage
MyApplication-aarch64.AppImage
For example:
OUTPUT_APPIMAGE="${APPIMAGE_DIR}/MyApplication-${ARCH}.AppImage"
The upload step must use the same path:
- name: Upload AppImage
uses: actions/upload-artifact@v4
with:
name: my-application-AppImage-${{ matrix.arch }}
path: AppImages/MyApplication-${{ matrix.arch }}.AppImage
Upload artifacts during local act runs
A workflow that builds packages will commonly finish with an artifact-upload step:
- name: Upload packages
uses: actions/upload-artifact@v4
with:
name: packages-${{ matrix.arch }}
path: |
packages/*.deb
AppImages/*.AppImage
if-no-files-found: error
On GitHub-hosted runners, the Actions runtime automatically provides the artifact service used by actions/upload-artifact.
A normal act invocation does not automatically start a local artifact server. Consequently, the following environment variables are empty unless artifact support is explicitly enabled:
ACTIONS_RUNTIME_URL
ACTIONS_RUNTIME_TOKEN
ACTIONS_RESULTS_URL
Enable the local artifact server with:
--artifact-server-path "$PWD/.artifacts"
For example:
act workflow_dispatch \
-W .github/workflows/test_appimage.yml \
--matrix arch:aarch64 \
--container-architecture linux/arm64 \
--artifact-server-path "$PWD/.artifacts" \
-P ubuntu-24.04-arm=catthehacker/ubuntu:act-24.04
The selected directory is created on the host and receives the artifacts uploaded during the local workflow run.
The act documentation currently states that its artifact server supports actions/upload-artifact@v3 and @v4, as well as the corresponding download-artifact versions, within the current workflow run. It does not support all remote artifact operations, such as downloading artifacts from another workflow run or from a repository using a GitHub token.
Store local artifacts in a dedicated directory
I use a repository-local directory:
readonly ARTIFACT_DIR="$PWD/.artifacts"
and prepare it before running the workflow:
rm -rf "${ARTIFACT_DIR}"
mkdir -p "${ARTIFACT_DIR}"
The launcher then passes it to act:
act workflow_dispatch \
-W ".github/workflows/${workflow}" \
--matrix "arch:${arch}" \
--container-architecture "${platform}" \
--artifact-server-path "${ARTIFACT_DIR}" \
--rm \
--pull \
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
-P ubuntu-24.04-arm=catthehacker/ubuntu:act-24.04
Why Node.js may be missing in container jobs
actions/upload-artifact is a JavaScript action. JavaScript actions require a Node.js runtime.
GitHub-hosted runners provide the runtime required to execute JavaScript actions. With act, however, the executable used for JS actions must be available in the relevant runner or job container. The act documentation explicitly notes that the container’s PATH must contain node for Node.js actions.
This becomes especially relevant for jobs that define their own container:
jobs:
build:
runs-on: ubuntu-24.04
container:
image: almalinux:9
The AlmaLinux container may contain the compilers and packaging tools required for the build, but not a Node.js executable. Shell steps can therefore work normally until the workflow reaches a JavaScript action, such as:
uses: actions/upload-artifact@v4
At that point, the local run may fail because node cannot be found.
A local-only installation step solves this:
- name: Install Node.js for act container-job compatibility
if: ${{ env.ACT == 'true' }}
shell: bash
run: |
set -euo pipefail
dnf install -y nodejs
node --version
act sets the ACT environment variable during local execution. Therefore, the condition prevents this compatibility step from running on GitHub-hosted runners.
Place the Node.js installation before JavaScript actions
The installation must occur before any JavaScript action that needs to execute inside the container:
steps:
- name: Install Node.js for act container-job compatibility
if: ${{ env.ACT == 'true' }}
shell: bash
run: |
set -euo pipefail
dnf install -y nodejs
node --version
- uses: actions/checkout@v4
- name: Build packages
run: ./packaging/build.sh
- name: Upload packages
uses: actions/upload-artifact@v4
with:
name: packages-${{ matrix.arch }}
path: packages/
if-no-files-found: error
Use unique artifact names for matrix jobs
Each matrix entry should upload to a distinct artifact name:
- name: Upload Debian packages
uses: actions/upload-artifact@v4
with:
name: deb-packages-${{ matrix.distribution }}-${{ matrix.arch }}
path: packages/*.deb
if-no-files-found: error
For AppImages:
- name: Upload AppImage
uses: actions/upload-artifact@v4
with:
name: appimage-${{ matrix.arch }}
path: AppImages/MyApplication-${{ matrix.arch }}.AppImage
if-no-files-found: error
Distinct names prevent matrix entries from attempting to upload different files to the same immutable artifact.
The complete local invocation
Combining architecture selection, runner-image mapping, and artifact storage gives:
act workflow_dispatch \
-W .github/workflows/test_appimage.yml \
--matrix arch:aarch64 \
--container-architecture linux/arm64 \
--artifact-server-path "$PWD/.artifacts" \
--rm \
--pull \
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
-P ubuntu-24.04-arm=catthehacker/ubuntu:act-24.04
The three relevant mechanisms are independent:
--matrix arch:aarch64
selects the desired matrix element.
--container-architecture linux/arm64
selects the architecture of the Docker container.
--artifact-server-path "$PWD/.artifacts"
starts the act’s local artifact service and selects where uploaded artifacts are stored.
Omitting the artifact option does not affect compilation, but actions/upload-artifact will not have a local artifact service to contact.
A complete local act launcher
The following script combines the main pieces:
#!/usr/bin/env bash
set -euo pipefail
readonly ROOT_DIR="$PWD"
readonly ARTIFACT_DIR="${ROOT_DIR}/.artifacts"
clean_local_ci_outputs() {
rm -rf \
build \
build-el* \
.xdg-data-* \
.tmp-el* \
"${ARTIFACT_DIR}" \
backend/tests/data/output
mkdir -p "${ARTIFACT_DIR}"
echo "Local CI outputs cleaned"
}
verify_arm64_emulation() {
docker run \
--privileged \
--rm \
tonistiigi/binfmt \
--install arm64
local detected
detected="$(
docker run \
--rm \
--platform linux/arm64 \
ubuntu:24.04 \
uname -m
)"
if [[ "${detected}" != "aarch64" ]]; then
echo \
"ARM64 emulation check failed: expected aarch64, " \
"detected ${detected}" \
>&2
return 1
fi
echo "ARM64 emulation is available"
}
run_act_arch() {
local workflow="$1"
local arch="$2"
local platform="$3"
echo
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Running act workflow"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Workflow: ${workflow}"
echo "Architecture: ${arch}"
echo "Container platform: ${platform}"
echo "Matrix filter: arch:${arch}"
echo "Artifact directory: ${ARTIFACT_DIR}"
act workflow_dispatch \
-W ".github/workflows/${workflow}" \
--matrix "arch:${arch}" \
--container-architecture "${platform}" \
--artifact-server-path "${ARTIFACT_DIR}" \
--rm \
--pull \
-P ubuntu-latest=catthehacker/ubuntu:act-latest \
-P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
-P ubuntu-24.04-arm=catthehacker/ubuntu:act-24.04
}
run_both_architectures() {
local workflow="$1"
run_act_arch \
"${workflow}" \
x86_64 \
linux/amd64
run_act_arch \
"${workflow}" \
aarch64 \
linux/arm64
}
print_usage() {
cat <<EOF
Usage:
$0 test_deb
$0 test_appimage
EOF
}
case "${1:-}" in
test_deb)
clean_local_ci_outputs
verify_arm64_emulation
run_both_architectures test_deb.yml
;;
test_appimage)
clean_local_ci_outputs
verify_arm64_emulation
run_both_architectures test_appimage.yml
;;
*)
print_usage
exit 1
;;
esac
Registering QEMU on every run is not strictly necessary. It can be installed once on the host.
Keeping the preflight check in the launcher has one advantage: a broken binfmt_misc configuration produces a clear error before a long compilation begins.
Common failures and their causes
The matrix says AArch64, but the container is x86_64
Output:
Expected project architecture: aarch64
Detected kernel architecture: x86_64
Detected Debian architecture: amd64
Likely causes:
- --container-architecture linux/arm64 was omitted.
- The x86 invocation was used accidentally.
- The ARM runner label was not mapped.
- Docker selected an amd64 image.
Correct invocation:
act workflow_dispatch \
-W .github/workflows/test_appimage.yml \
--matrix arch:aarch64 \
--container-architecture linux/arm64 \
-P ubuntu-24.04-arm=catthehacker/ubuntu:act-24.04
The ARM64 container immediately stops
Possible output:
exec format error
or:
container is not running
Likely cause:
- QEMU or binfmt_misc is unavailable or incorrectly configured.
Reinstall the interpreter registration:
docker run \
--privileged \
--rm \
tonistiigi/binfmt \
--install arm64
Then test Docker directly:
docker run \
--rm \
--platform linux/arm64 \
ubuntu:24.04 \
uname -m
Do not continue debugging act until this prints:
aarch64
Ordinary ARM64 binaries work, but an AppImage fails
Output:
cannot execute binary file: Exec format error
Possible cause:
- QEMU is rejecting the AppImage-specific AI\x02 bytes in the ELF identification area.
Check the bytes:
dd \
if="${appimage}" \
bs=1 \
skip=8 \
count=3 \
status=none |
od -An -tx1 |
tr -d ' \n'
If the result is:
414902
patch the temporary local copy:
dd \
if=/dev/zero \
of="${appimage}" \
bs=1 \
seek=8 \
count=3 \
conv=notrunc \
status=none
The AppImage starts but reports a FUSE problem
Likely cause:
- The ELF runtime started correctly, but the container cannot mount the embedded filesystem.
Use:
export APPIMAGE_EXTRACT_AND_RUN=1
Then execute the AppImage normally:
./MyApplication.AppImage --version
For an older runtime:
./MyApplication.AppImage --appimage-extract
./squashfs-root/AppRun --version
APPIMAGE_EXTRACT_AND_RUN=1 does not fix Exec format error
This is expected when the failure occurs before the AppImage runtime starts.
Extract-and-run mode is interpreted by the AppImage runtime. If QEMU or the kernel rejects the ELF header first, the runtime never reads the environment variable.
Apply the magic-byte workaround first, then enable extract-and-run mode.
The build succeeds, but the artifact upload reports no files
Example:
No files were found with the provided path
Likely cause:
- The packaging script and workflow disagree about the output location or filename.
Inspect the actual output:
find . \
-type f \
-name '*.AppImage' \
-print
Then make the upload path exact:
path: AppImages/MyApplication-${{ matrix.arch }}.AppImage
Selecting one architecture still runs several jobs
Likely cause:
- The matrix contains another dimension.
For example:
act --matrix arch:x86_64
still selects every matching operating-system entry.
Filter all required dimensions:
act \
--matrix arch:x86_64 \
--matrix os:ubuntu-24.04
The ARM64 job is much slower
Likely cause:
- Compilation, linking, and compression are running through QEMU user-mode emulation.
This is expected. The local ARM64 run is intended to detect correctness and packaging problems, not to measure native ARM64 performance.
Conclusion
Running a basic GitHub Actions workflow locally with act is simple. Running a real multi-architecture packaging matrix locally is not, because several independent systems must agree.
The first important lesson is that an architecture label is not an architecture guarantee.
A matrix value of:
aarch64
does not automatically create:
linux/arm64
and a package labeled:
arm64
does not prove that every executable inside it is an AArch64 binary.
Always check the environment and the produced artifacts.
The second lesson is that AppImages are unusual executables. A type-2 AppImage combines an ELF runtime with an embedded filesystem and includes its own identification bytes inside the ELF header.
Under QEMU user-mode execution, those bytes can cause the file to be rejected before the AppImage runtime starts. In that case, enabling extract-and-run mode is not enough. The three bytes at offsets 8–10 must first be cleared on a temporary local copy. Once this workaround is applied at the correct point, particularly before executing the ARM64 appimagetool AppImage,the entire packaging workflow can be exercised locally through act.
The real GitHub-hosted run is still required, but it is no longer the place where all missing packages, incorrect paths, and shell typos are first discovered.