Compiling MQL4 and MQL5 with Docker and GitHub Actions

Compiling MQL4 and MQL5 with Docker and GitHub Actions

7 November 2026, 04:09
George Ciprian Mandache
0
12

All my MQL code lives in one repository: a library of shared headers and the experts, indicators and scripts built on it. Every program ships for MetaTrader 5 and MetaTrader 4 from the same source. I wanted both platforms compiled on every change, from a script, without opening MetaEditor, so that a header edit which breaks a program I never opened fails right away instead of reaching a user.

MetaEditor compiles from the command line, and it runs under Wine. I put Wine and the setup around MetaEditor into a Docker image that installs both MetaTrader platforms on its first run, so one command compiles MQL4 and MQL5 on any machine that runs Docker: a Linux box, Windows or macOS with Docker Desktop, or a CI runner. Making MetaEditor run unattended, with no screen, took me much longer than I expected. This post covers what I ran into, and the image and GitHub Action I built out of it. I measured everything here under Wine on a Linux host, with MetaEditor 5.0.0.6252 from MetaTrader 5 and 5.0.0.2425 from MetaTrader 4. I have not run the image under Docker Desktop myself, and I have not checked MetaEditor's own behavior on Windows.

The short version

From a directory that holds your MQL5 tree:

docker run --rm --init \
  --user "$(id -u):$(id -g)" \
  -v "$PWD":/src \
  -v mql-compiler-cache:/cache \
  psyb0t/mql-compiler:latest --text MQL5

The first run downloads the MetaTrader 4 and MetaTrader 5 installers, refuses them unless MetaQuotes signed them, and installs them under Wine into the mql-compiler-cache volume. That takes a minute or two. After that a compile takes seconds. The EX5 files land next to the sources and belong to your user. The exit status is 0 when every file compiled, 1 when any file failed, and 2 for a bad path or a failed setup. The image is built for amd64 only, because MetaTrader 4's MetaEditor is a 32-bit program and needs the 32-bit part of Wine. The image has no ARM build, so on an Apple Silicon Mac Docker Desktop has to emulate amd64 to run it. I have not tested that.

On Ubuntu 26.04 and other hosts with Linux 7.0, the command also needs a seccomp profile. The section "Linux 7.0 and seccomp" below shows how to make one in one line.

In a GitHub workflow:

- uses: psyb0t/docker-mql-compiler@v0.5.5
  with:
    paths: MQL5
    werror: true

Each compiler error and warning then shows up as an annotation on its line in the pull request. The full workflow is near the end of this post.

What MetaEditor's command line offers

The MetaEditor manual documents the keys on its Integration with other IDEs page:

Key What it does
/compile:"path" Compiles one file, or every file in a directory. Subdirectories are not included.
/log Writes a log next to the source. In my runs, /log:"path" wrote it to that path, which the manual does not mention.
/include:"path" The MQL5 (or MQL4) directory that #include <...> is resolved from.
/s Checks the syntax and writes no EX5. Use it with /log.

On paper that is all a build script needs. In practice six things got in my way.

The exit status is a count

My first script treated exit status 0 as success, the way it works for almost every compiler, and it reported a broken build as a good one. Then I read it backwards for a while, as a reversed success flag, and my own documentation said so. That was wrong too. Here is what I measured:

Run Exit status
One file that compiles 1
One file with a typo 0
A directory with 3 good files and 1 broken one 3

The exit status is the number of files that compiled. The manual does not document it, so my build ignores it and reads the log.

The log is UTF-16, and the verdict is the Result line

The log file starts with the bytes FF FE, the byte order mark of UTF-16 little-endian. A plain grep for "Result" finds nothing in it until you convert it:

iconv -f UTF-16 -t UTF-8 PositionCount.log | tr -d '\r'

For a script with a typo, the converted log reads like this (with the directories shortened to ... here):

Z:\...\Typo.mq5 : information: compiling Z:\...\Typo.mq5
Z:\...\Typo.mq5 : information: including Z:\...\Include\TradeTools\OpenTrades.mqh
Z:\...\Typo.mq5(17,49) : error 256: undeclared identifier 'CountOpenPosition'
Z:\...\Typo.mq5(17,67) : error 152: 'symbol' - some operator expected
Result: 2 errors, 0 warnings

Errors and warnings have the form path(line,column) : severity code: text, which one regular expression can split. The paths are Windows paths, because Wine maps the Linux root directory to drive Z:. A clean compile ends with a line such as "Result: 0 errors, 0 warnings, 848 ms elapsed, cpu='X64 Regular'". This is the check I use, with one file per compile and one log per file:

result=$(iconv -f UTF-16 -t UTF-8 "$log" | tr -d '\r' | grep '^Result:' | tail -n 1)
case "$result" in
    "Result: 0 errors"*) echo "compiled: $result" ;;
    *) echo "failed: ${result:-no Result line}"; exit 1 ;;
esac

A missing Result line counts as a failure. I have seen MetaEditor exit without writing one, as the sections below show.

Do not run this check on a directory compile. There the log holds one Result line per file, and a broken file in the middle of the list hides behind the clean ones. Directory mode has two more catches: the manual says it skips a source that already has a compiled version, and each of its Result lines said "elapsed without optimizations" in my runs, while the same files compiled one by one did not. My build compiles each file on its own.

A syntax check with /s writes no Result line at all. It writes a lowercase "information: result 0 errors, 0 warnings" line instead.

One more thing about the old EX5. When a compile fails, MetaEditor deletes the EX5 from the last good build. When it writes no result, the old EX5 stays, and it looks current. My build deletes the EX5 before each compile.

Each MetaEditor compiles only its own language

The MQL4 notes for build 600 announced that both languages would compile from either MetaEditor. In my builds, each one refused the other's files with error 208:

MetaEditor64.exe, given a .mq4 file:
(0,0) : error 208: unsupported file extension

metaeditor.exe, given a .mq5 file:
(0,0) : error 208: MQL5 files compilation is not supported, syntax checking only

So a build needs both platforms installed, and each one needs a source with its own extension. My sources live only under MQL5, and the build generates the MetaTrader 4 tree before each compile:

rm -rf MQL4
cp -r MQL5 MQL4
find MQL4 -name '*.mq5' -exec sh -c 'mv "$1" "${1%.mq5}.mq4"' _ {} \;

Whatever differs between the platforms goes behind the __MQL5__ macro, inside shared headers, so the programs themselves have no platform code. A small example that counts the open market positions on a symbol:

//+------------------------------------------------------------------+
//|                                                   OpenTrades.mqh |
//+------------------------------------------------------------------+
#property strict

#ifndef TRADETOOLS_OPENTRADES_MQH
#define TRADETOOLS_OPENTRADES_MQH

//+------------------------------------------------------------------+
//| Counts the open market positions on a symbol. Pending orders are |
//| not counted on either platform.                                  |
//+------------------------------------------------------------------+
int CountOpenPositions(const string symbol)
  {
   int count=0;
#ifdef __MQL5__
   for(int i=PositionsTotal()-1; i>=0; i--)
     {
      if(PositionGetSymbol(i)==symbol)
         count++;
     }
#else
   for(int i=OrdersTotal()-1; i>=0; i--)
     {
      if(!OrderSelect(i,SELECT_BY_POS,MODE_TRADES))
         continue;
      if(OrderSymbol()!=symbol)
         continue;
      if(OrderType()==OP_BUY || OrderType()==OP_SELL)
         count++;
     }
#endif
   return count;
  }

#endif
//+------------------------------------------------------------------+

On MetaTrader 4, the trading pool that OrderSelect reads with MODE_TRADES also holds pending orders, so the loop keeps only OP_BUY and OP_SELL. #property strict is an MQL4 directive. MQL5 accepts it, and both files carry it so that the MetaTrader 4 build compiles in strict mode.

Compiling both platforms on every change is what catches the forgotten #ifdef. With the MetaTrader 4 branch deleted from the header, the MetaTrader 5 build stays clean and the MetaTrader 4 one fails (directories shortened to ... here):

OK   MQL5/.../PositionCount.mq5 -> MQL5/.../PositionCount.ex5 (0 warnings)
FAIL MQL4/.../PositionCount.mq4 (2 errors, 1 warnings)
  MQL4/.../OpenTrades.mqh(16,14) : error 168: 'PositionsTotal' - function not defined
  MQL4/.../OpenTrades.mqh(18,10) : error 168: 'PositionGetSymbol' - function not defined
  MQL4/.../OpenTrades.mqh(18,10) : warning 181: implicit conversion from 'number' to 'string'

The errors point at the generated MQL4 copy. The line to fix has the same number in the file under MQL5.

The standard library is not there after the install

The first version of my image compiled my own code fine and failed on every #include <Trade/Trade.mqh> with "file not found". The silent install (the installer's /auto key) puts MetaEditor on disk, but the MQL5 directory with the standard headers does not exist yet. The terminal unpacks it the first time it starts. So the setup starts the terminal once in portable mode, which keeps its data in the installation directory, waits until MQL5/Include/Trade/Trade.mqh exists, and stops Wine:

wine mt5setup.exe /auto
wineserver -w
cd "<installation directory>"
wine terminal64.exe /portable &
# wait until MQL5/Include/Trade/Trade.mqh exists
wineserver -k

The installer hands its work to a child process, so its own exit status tells you nothing. Check that MetaEditor64.exe exists afterwards.

/include replaces the default, it does not add to it

With no /include, a source outside the terminal's directories still found <Trade/Trade.mqh>: MetaEditor falls back to its own terminal's MQL5 directory. With /include pointing at my project's MQL5 directory, the standard headers were gone (error 106, file not found). Pointing it at the terminal's directory lost my own headers instead. /include takes one root, and a project needs at least two sets of headers in it.

My image builds a temporary MQL5 directory for each run and fills it with links: the terminal's standard Include first, then any extra header directories, then the project's own Include, so a project header with the same path as a standard one wins. The project's other subdirectories, such as Files and Images, are linked next to it so that #resource paths still resolve. MetaEditor gets that directory as /include, and the image deletes it after the run. Nothing gets copied into the project or the terminal.

Spaces in paths

A source whose path had a space in it compiled to nothing: no log and no EX5, with the keys passed through Wine as one argument each ("/compile:Z:\a b\x.mq5"). The manual's own quoting, /compile:"path" with quotes around the path only, worked from a batch file run with wine cmd. My image avoids the problem by compiling such a file through a link whose path has no spaces.

Two Wine problems that cost me the most time

A dialog nobody could see

Once the installers started delivering MetaTrader 5 build 6304, a CI run of my image sat in the first install for six hours, until GitHub cancelled it. It printed no error and left no log. From an empty cache on my own machine, the install never finished either.

From build 6304 on, MetaTrader 5 under Debian's Wine 10 stops at a modal "A debugger has been found running in your system" window and waits for a click. On a build server the screen is Xvfb, a virtual display nobody looks at, so it waits forever. The standard library never gets unpacked, and MetaEditor 6304's /compile exits without writing a log. Wine 11 from WineHQ runs build 6304 without the dialog.

Two lessons came out of it. Install Wine 11 from WineHQ if your distribution ships an older one. And put a time limit on every step that runs Windows software. Mine are 600 seconds for an installer and 300 seconds for creating the Wine prefix, and each one logs a line when it runs out. That way a hang shows up as a clear error in a few minutes.

Linux 7.0 and seccomp

Then GitHub moved ubuntu-latest to Ubuntu 26.04, which runs Linux 7.0, and Wine stopped before it could even create its prefix:

wine: socket : Function not implemented

No feature was missing. Docker's default seccomp profile, the filter that decides which kernel calls a container may make, refuses the userfaultfd call on those hosts, and Wine 11 makes that call while it starts. I found it by running the container with a profile that logs refused calls instead of blocking them, and reading the kernel's audit lines.

The fix keeps the filter: take Docker's own default profile and allow that one call.

url=https://raw.githubusercontent.com/moby/profiles/seccomp/v0.3.0/seccomp/default.json
rule='{"names": ["userfaultfd"], "action": "SCMP_ACT_ALLOW"}'
curl -fsSL "$url" | jq --argjson rule "$rule" '.syscalls += [$rule]' > wine.json

Then add --security-opt seccomp=wine.json to the docker run command. On older kernels the extra rule changes nothing, so I pass the profile everywhere. The GitHub Action passes its own copy. Do not reach for seccomp=unconfined instead: that turns the whole filter off.

The GitHub workflow

This workflow generates the MetaTrader 4 tree and compiles both, with warnings counted as failures:

name: build

on:
  push:
    branches: [main]
  pull_request:

jobs:
  compile:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Stage the MQL4 copy
        run: |
          rm -rf MQL4
          cp -r MQL5 MQL4
          find MQL4 -name '*.mq5' -exec sh -c 'mv "$1" "${1%.mq5}.mq4"' _ {} \;

      - id: compile
        uses: psyb0t/docker-mql-compiler@v0.5.5
        with:
          paths: |
            MQL5
            MQL4
          werror: true

Replace main with your default branch. Limiting push to that branch keeps a pull request from the same repository from running the job twice.

A few things to know about it:

  • GitHub shows only a limited number of annotations per step. The job summary lists every file with its error and warning counts, and the results output points at a JSON file with every message.
  • An error inside a MetaQuotes standard header has no file in your repository, so its annotation carries no file.
  • The step sets the outputs total, compiled and failed. A later step reads them as steps.compile.outputs.failed, and after a failed compile it needs if: always() to run at all.
  • A failed job does not block a merge by itself. Make the job a required status check in the branch protection rules.
  • The installed toolchain is about 2 GB, and the action keeps it in the Actions cache, which saves only after a successful job. GitHub reuses a cache saved by a pull request only in that pull request, so until the first green push to the main branch, most runs install the toolchain again.
  • Pinning the action does not pin MetaEditor. The build comes from the installers on the day the cache was filled, and a new action version starts a new cache.

Two things you still have to handle yourself

MetaEditor reads a source without a byte order mark in the ANSI code page, so a source with Cyrillic, Chinese or any other non-ASCII text in its strings compiles to the wrong characters. My build adds a UTF-8 byte order mark to every such file in the staged copy. Neither the image nor the action does this. In the workflow above, it fits at the end of the staging step:

find MQL5 MQL4 -type f \( -name '*.mq5' -o -name '*.mq4' -o -name '*.mqh' \) -print0 |
  while IFS= read -r -d '' f; do
    LC_ALL=C grep -q '[^[:print:][:space:]]' "$f" || continue
    [ "$(LC_ALL=C head -c 3 "$f")" = $'\xEF\xBB\xBF' ] && continue
    { printf '\xEF\xBB\xBF'; cat "$f"; } > "$f.bom" && mv "$f.bom" "$f"
  done

MetaTrader 4's compiler also fails now and then with "internal error #-1031" on code that compiles fine on the next try. My own build retries such a file up to three times. The action does not retry, so when it happens, run the job again.

Other options

Without --text, the image prints one JSON object per line: a "file" record per source with its status, output path, error and warning counts, and every message with its file, line, column, code and text, then a summary:

{"type":"summary","total":1,"compiled":0,"failed":1,"werror":false}
  • --format gnu prints path:line:column: severity: text [code] lines, which VS Code, Vim and Emacs can jump to.
  • --werror counts a file with warnings as failed.
  • -I DIR adds a header directory outside the MQL5 tree, such as a shared library in its own repository. With docker run, that directory needs its own -v mount.
  • --watch compiles again whenever a .mq5, .mq4 or .mqh file under the given paths changes.
  • --version prints the MetaEditor builds in the cache. The cache keeps the build that was installed first, and --update reinstalls from the current installers.

Since I moved to this setup, a header change that breaks the MetaTrader 4 build of some program I did not touch fails in seconds on my machine, and a change that gets past me fails the pull request.