Aug 19, 2026by Benjamin Gallois

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:

  1. Modify the workflow or application.
  2. Commit the change.
  3. Push it to GitHub.
  4. Wait for a runner.
  5. Consume unnecessary GitHub compute.
  6. Discover a missing package, an incorrect artifact path, or a shell error.
  7. 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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.