Das sind die Zeilen, für die das ganze Projekt existiert. Alles andere in docker-mql-compiler bereitet sie vor oder räumt hinter ihnen auf:
# MetaEditor's exit code is backwards (1 usually means success); the log's
# Result line is the verdict, and its stderr is Wine noise.
wine "$editor" \
"/compile:$(winepath -w "$STAGED_SOURCE")" \
"/log:$(winepath -w "$log_file")" \
"/inc:$(winepath -w "$MERGED_ROOT")" >/dev/null 2>&1 || trueDas ist MetaEditor von MetaQuotes, das einzige Ding auf diesem Planeten, das aus einer .mq5 eine .ex5 macht, eine Windows-GUI, die noch nie was von Linux gehört hat, gestartet von einem Bash-Skript in einem Wine-Container, mit Xvfb als Fake-Display, auf das kein Mensch schaut. Achte auf das || true. Der Exit-Code fliegt absichtlich in die Tonne, weil er lügt. Mein Editor, meine Agenten und meine CI leben alle auf Linux, und ich hatte die Schnauze voll davon, dass jemand an einer Windows-Kiste rumfummelt, um auf “Compile” zu klicken und Fehler von Hand aus einem Log-Fenster abzuschreiben. Also lese ich entrypoint.sh in der Reihenfolge, in der es läuft: vom ersten Download bis zur JSON-Zeile, die ein Agent am Ende liest.
Erst ein bisschen Kontext. mt5-httpapi hat schon eine POST /compile-Route, MQL5-Quellcode rein als JSON, eine .ex5 raus als base64. Nur lebt die in einer kompletten Windows-VM unter KVM, die Live-Terminals betreibt und mit echtem Geld tradet, kann nur MQL5, lehnt Includes mit absoluten Pfaden oder .. ab, und gemeinsame Bibliotheken musst du vorher in ihren Compile-Baum hochladen. Das hier kompiliert ein Verzeichnis voller MT4- und MT5-Code auf einem Laptop oder einem GitHub-Runner, ohne dass Windows überhaupt hochfährt.
verify_installer: von MetaQuotes signiert oder abgelehnt
Das Image ist Debian trixie mit Wine (64-Bit plus die 32-Bit-Hälfte, weil der uralte MetaEditor von MT4 32-Bit-Windows-Code ist) und kein einziges Byte von MetaQuotes. MetaTrader gehört nicht mir, ich darf es nicht weiterverteilen, also lädt der erste Lauf die Installer runter (standardmäßig die von RoboForex, überschreibbar mit MQL_COMPILER_MT4_URL und MQL_COMPILER_MT5_URL). Er holt nur, was der Lauf braucht: nur .mq5-Dateien heißt, nur MT5 wird installiert. Ungefähr anderthalb Minuten beim ersten Mal, danach Sekunden.
Bevor wine einen Installer anfasst, prüft osslsigncode seine Authenticode-Signatur gegen das CA-Bundle des Images, und dann prüft das hier, wer ihn tatsächlich signiert hat:
require_signer() {
local name="$1" report="$2" subject
subject="$(awk '/^Signer.s certificate:/ { found = 1 } found && /Subject:/ { print; exit }' <<<"$report")"
if [[ "$subject" != *"/CN=$SIGNER_PATTERN"* ]]; then
log ERROR "installer is validly signed but not by $SIGNER_PATTERN file=$name signer=${subject#*Subject: }"
return 1
fi
log INFO "installer signature ok signer=$SIGNER_PATTERN file=$name"
}SIGNER_PATTERN ist MetaQuotes. Es zählt nur das Zertifikat des Signierers, nicht die Kette und nicht die Zeitstempel-Zertifikate, die im selben Report rumliegen. Eine gültige Signatur von jemand anderem ist trotzdem eine Ablehnung. Wenn der Sperrlisten-Server der CA down ist, versucht verify_installer es dreimal, prüft dann Digest, Kette und Signierer ohne die Sperrabfrage und loggt eine Warnung mit reason=crl_unreachable, damit du weißt, dass das passiert ist.
unpack_stdlib: das Terminal einmal starten, damit Trade.mqh existiert
Der Installer legt die Standardbibliothek nicht auf die Platte. Die entpackt das Terminal beim ersten Start. Installier MetaEditor unter Wine, lass das weg, und jedes #include <Trade/Trade.mqh> geht hops. Also startet das Setup jedes Terminal headless im Portable-Modus und wartet auf die Marker-Datei:
(cd "$terminal" && wine "$(terminal_name "$platform")" /portable >/dev/null 2>&1 &)
while [[ ! -f "$marker" ]] && ((waited < STDLIB_UNPACK_TIMEOUT_SECONDS)); do
sleep "$POLL_SECONDS"
waited=$((waited + POLL_SECONDS))
doneDer Marker ist Include/Trade/Trade.mqh für MT5 und stdlib.mqh für MT4. Sobald er auftaucht, killt wineserver -k das Terminal, das sich von selbst nie beendet. Die installierten Terminals werden dann aus Program Files in schlichte portable Verzeichnisse verschoben, drive_c/terminals/mt4 und drive_c/terminals/mt5, im Docker-Volume mql-compiler-cache. Grob 2 GB, ein Unterverzeichnis pro User-ID. Der Container läuft mit deiner UID, also gehören die kompilierten Dateien dir und nicht root.
build_merged_root: vier Include-Schichten, deine obendrauf
MetaEditor liest #include <...> aus genau einem Include-Verzeichnis. Du brauchst mindestens zwei, die Standardbibliothek von MetaQuotes und deine eigenen Header. Das ist die Funktion, die das Tool überhaupt erst brauchbar macht:
cp -rs "$(stdlib_root "$platform")/Include" "$MERGED_ROOT/Include"
for include_dir in ${EXTRA_INCLUDES[@]+"${EXTRA_INCLUDES[@]}"}; do
cp -rs --remove-destination "$include_dir/." "$MERGED_ROOT/Include/"
done
MERGED_ROOTS[$key]="$MERGED_ROOT"
[[ -n "$user_root" ]] || return 0
for entry in "$user_root"/*; do
[[ -e "$entry" ]] || continue
name="$(basename "$entry")"
if [[ "${name,,}" == "$INCLUDE_FOLDER" && -d "$entry" ]]; then
cp -rs --remove-destination "$entry/." "$MERGED_ROOT/Include/"
continue
fi
ln -s "$entry" "$MERGED_ROOT/$name"
doneLies sie von oben nach unten, und du hast die Reihenfolge der Schichten:
- Das Standard-
Includedes Terminals (Trade/,Arrays/,stdlib.mqh, der ganze Kram). - Jedes
-I-Verzeichnis, in der Reihenfolge, in der du sie angegeben hast. - Dein eigenes
Include.--remove-destinationheißt, bei einem Konflikt gewinnt die spätere Schicht, und deine kommt zuletzt. GepatchteTrade/Trade.mqhin deinem Baum? Kompiliert wird deine. - Links auf den Rest deines
MQL4/MQL5-Verzeichnisses (Files,Imagesund Konsorten), damit#resource "\\Files\\data.txt"aufgelöst wird.
Das sind alles Symlinks (cp -rs) in einem Staging-Bereich, einmal pro Plattform und User-Verzeichnis pro Durchlauf gebaut. In deine Verzeichnisse wird nichts geschrieben, und wenn der Lauf endet, ist alles weg. Welches MQL4– oder MQL5-Verzeichnis als deins zählt, ist einfach das nächste oberhalb der Quelldatei. Die Endung allein wählt den Compiler, .mq4 an den metaeditor.exe von MT4, .mq5 an den MetaEditor64.exe von MT5.
stage_source: ein Link um die Leerzeichen herum
Dein Terminal liegt in MT5 - SomeBroker? MetaEditors /compile und /inc verschlucken sich beide an Leerzeichen. Also wird eine Quelle mit Leerzeichen im Pfad über einen Link kompiliert:
[[ "$source" =~ [[:space:]] ]] || return 0
link="${SOURCE_LINKS[$root]:-}"
if [[ -z "$link" ]]; then
link="$STAGING_DIR/sources/${#SOURCE_LINKS[@]}"
mkdir -p "$STAGING_DIR/sources"
ln -s "$root" "$link"
SOURCE_LINKS[$root]="$link"
fi
STAGED_SOURCE="$link${source#"$root"}"Der Link zeigt auf das ganze MQL4/MQL5-Verzeichnis, nicht auf die Datei, also lösen sich "../"-Includes darin weiterhin auf. Aus demselben Grund liegt auch der zusammengeführte Include-Root im Staging-Bereich: Das Verzeichnis eines gemounteten Terminals hat oft Leerzeichen im Namen.
compile_one: erst löschen, dann dem Exit-Code nicht trauen
Direkt vor dem wine-Aufruf vom Anfang dieses Posts steht das hier:
# An old .ex4/.ex5 must never outlive a compile that did not produce a new
# one. MetaEditor removes it on compile errors, but not when it crashes or
# writes no result.
rm -f "$output"Was auch immer nach dem Lauf als .ex4/.ex5 da liegt, kommt aus diesem Lauf. Kein altes Binary, das sich als frisch ausgibt, nachdem MetaEditor auf die Fresse geflogen ist. Die Dateien werden nacheinander kompiliert, nie parallel, weil zwei MetaEditors gleichzeitig unter Wine genau die Art ist, wie man Dämonen beschwört.
Nach dem Aufruf kommt das Urteil aus dem Log, nicht aus dem Exit-Code:
result="$(grep -E '^Result: ' <<<"$lines" | tail -n 1)" || result=""Der Kommandozeilenmodus von MetaEditor gibt meistens 1 zurück, wenn alles geklappt hat. Klemm das naiv in eine CI, und du hast eine Pipeline, die bei Erfolg fehlschlägt. Die Result:-Zeile enthält die Anzahl der Fehler und Warnungen, zwei sed-Aufrufe ziehen sie raus, und --werror macht aus Warnungen einen Fehlschlag. Gar keine Result:-Zeile heißt, MetaEditor ist abgeschmiert oder hat nichts geschrieben, und der Datensatz trägt dann seine rohen log-Zeilen, statt so zu tun als ob.
read_log: Windows-Pfade zurück in deine
MetaEditor schreibt sein Log als UTF-16, mit Windows-Pfaden, die in einen Staging-Bereich zeigen, nach dem du nie gefragt hast. read_log jagt es durch iconv, macht aus Z:\ wieder / und löst jeden Staging-Pfad mit readlink zur echten Datei dahinter auf. Dein Header für einen eigenen Header, der der Toolchain für einen Standard-Header, und das aktuelle Verzeichnis wird vorne abgeschnitten. Das ist reine String-Ersetzung in Bash, kein sed, also kann kein Zeichen in einem Pfad sie kaputt machen. Der Gewinn: Ein Fehler in einem Header zeigt auf den Header, nicht auf den EA, der ihn eingebunden hat.
parse_messages: eine Regex, ein JSON-Datensatz
Jede Fehler- und Warnungszeile im Log sieht aus wie path(line,column) : error 256: text, und ein einziges jq-Capture macht Objekte daraus:
| capture("^(?<file>.*)\\((?<line>[0-9]+),(?<column>[0-9]+)\\) : (?<severity>error|warning) (?<code>[0-9]+): (?<text>.*)$")?Ohne --text ist stdout JSON Lines: ein file-Datensatz pro Quelle, sobald sie fertig ist, dann ein summary. Der Fortschritt geht nach stderr, ebenfalls als JSON-Zeilen, also lässt 2>/dev/null nur die Ergebnisse übrig.
{"type":"file","source":"MQL5/Experts/Scalper.mq5","platform":"mt5","status":"failed","output":null,"errors":1,"warnings":0,"messages":[{"severity":"error","file":"MQL5/Experts/Scalper.mq5","line":41,"column":7,"code":256,"text":"undeclared identifier 'lots'"}]}
{"type":"summary","total":1,"compiled":0,"failed":1,"werror":false}Jede Meldung ist {severity, file, line, column, code, text}, und genau das braucht ein LLM, das EAs schreibt, statt eines Screenshots von einem Log-Fenster. --format gnu rendert denselben Datensatz als path:line:column: error: text [code]-Zeilen, zu denen VS Code, vim und emacs direkt springen. Die Exit-Codes sind absichtlich langweilig: 0 alles kompiliert, 1 mindestens eine fehlgeschlagen, 2 kaputte Argumente oder ein Setup, das nicht installieren konnte.
mql-compiler 2>/dev/null | jq -r 'select(.status == "failed") | .source'Das ist der komplette “Was ist kaputt”-Report. Das Repo bringt außerdem einen Agent-Skill mit, der bei jedem Release auf ClawHub veröffentlicht wird und Claude Code, Codex, OpenClaw und peen beibringt, den Befehl zu installieren, zu kompilieren und diese Datensätze tatsächlich zu lesen, statt sich zusammenzuhalluzinieren, dass der EA sauber gebaut hat.
Der Wrapper, der das alles aufruft
Nichts von alldem läuft, bevor irgendwas den Container startet. Du brauchst Docker auf einer x86-64-Kiste. Es gibt kein ARM-Image, weil der MetaEditor von MT4 die i386-Hälfte von Wine braucht, also ist Apple Silicon raus, außer Docker emuliert dir amd64.
# per user, lands in ~/.local/bin
curl -fsSL https://raw.githubusercontent.com/psyb0t/docker-mql-compiler/master/install.sh | bash
# system wide, /usr/local/bin
curl -fsSL https://raw.githubusercontent.com/psyb0t/docker-mql-compiler/master/install.sh | sudo bash -s -- --systemDer Installer pinnt das neueste Release-Image und schreibt genau eine Datei, den Befehl mql-compiler, der ein dünnes docker run ist. Nochmal ausführen zum Upgraden, --rolling pinnt stattdessen :latest, --uninstall entfernt den Befehl und sein Image. Das Cache-Volume bleibt, weil es sich alle User auf der Kiste teilen.
mql-compiler # every .mq4 and .mq5 under the current directory
mql-compiler MQL5/Experts/Scalper.mq5 # one source
mql-compiler --text MQL5 MQL4 # both platforms, human output
mql-compiler --watch MQL5/Experts # recompile on save until Ctrl-C
mql-compiler --werror MQL5 # warnings fail the run
mql-compiler -I ~/libs/mql5 MQL5 # shared headers outside your tree
mql-compiler --mt5-terminal ~/mt5 MQL5 # use a portable terminal already on disk
mql-compiler --setup # install the toolchain, compile nothing
mql-compiler --version # image version and MetaEditor builds
mql-compiler --update # reinstall from the newest installersLeg die Quellen unter ein Verzeichnis namens MQL4 oder MQL5, gleiches Layout wie das Datenverzeichnis eines Terminals, die Header in dessen Include. Argumente können Dateien oder Verzeichnisse sein, relativ oder absolut, irgendwo auf der Kiste, Plattformen gemischt. Der Wrapper mountet jedes benötigte Verzeichnis unter demselben Pfad in den Container und startet einen einzigen Container für den ganzen Lauf. Ruf ihn ohne Argumente aus / auf, und er weigert sich, weil deine komplette Platte nach EAs abzusuchen eine bescheuerte Idee ist.
–watch
--watch hält den Container offen, kompiliert, hängt sich dann an inotifywait und macht einen weiteren kompletten Durchlauf, sobald sich eine .mq4, .mq5 oder .mqh unter deinen Pfaden oder in einem -I-Verzeichnis ändert. Zwischen den Durchläufen gibt er das Cache-Lock frei, also kommt ein einmaliger Compile aus einem anderen Terminal trotzdem dran. Ctrl-C beendet mit 130, sauber.
–mt4-terminal and –mt5-terminal
Das Terminal deines Brokers läuft mit einem bestimmten MetaEditor-Build, und du willst genau den? Richte diese Flags auf ein portables Terminal-Verzeichnis, das schon auf der Platte liegt, und compile_one lässt dessen MetaEditor gegen dessen Standardbibliothek laufen. Diese Plattform wird nie runtergeladen, das Terminal wird read-only gemountet, und lässt du das Flag beim nächsten Mal weg, bist du wieder auf dem Build aus dem Cache. Das Terminal muss einmal gestartet worden sein, damit es seine Header entpackt hat, sonst gibt es Exit 2, bevor irgendwas kompiliert. Weil die Terminals im Cache in dieselbe portable Form verschoben werden, gibt es keinen Unterschied zwischen meinen und deinem.
Der Cache behält für immer den MetaEditor-Build, den er als ersten installiert hat. --version sagt dir, auf welchen Builds du bist, --update installiert aus den neuesten Installern neu, und wenn das schiefgeht, kommt die alte Toolchain unangetastet zurück.
Derselbe Entrypoint auf einem GitHub-Runner
- uses: actions/checkout@v6
- uses: psyb0t/docker-mql-compiler@master
with:
paths: |
MQL5/Experts
MQL4/Indicators/Bands.mq4Diese JSON-Datensätze liest die Action. Compile-Fehler lassen den Job scheitern und landen als Annotations auf genau den Quellzeilen im PR. Die Inputs sind paths, include, werror, image, cache und cache-dir. Mit cache an (der Default) liegt die 2-GB-Toolchain im Actions-Cache, also frisst nur der erste Lauf die 90-Sekunden-Installation. Die Outputs sind results (die JSON-Lines-Datei), total, compiled und failed, und die Job-Zusammenfassung bekommt eine Tabelle mit jeder Datei. Ein EA, der nicht kompiliert, kommt nie wieder nach main, und keiner hat Windows angefasst, um das rauszufinden.
Die Regex, die ihren eigenen Fix-Commit bekam
Das erste Release ging ohne sie raus. Der allernächste Fix-Commit in der History existiert wegen genau dieser einen Zeile:
# osslsigncode's words when it could not get a CRL (its own typo included).
readonly CRL_FETCH_FAILURE="Fai(le)?d to (decode|download|get|fetch) CRL"Auf den Runnern von GitHub kam die Sectigo-Sperrliste, die osslsigncode abrief, abgeschnitten an, und jeder erste Lauf fiel bei der Signaturprüfung eines völlig einwandfreien MetaQuotes-Installers durch, der Test-Job von v0.1.0 eingeschlossen. Das erneut zu versuchen, ohne auch eine wirklich kaputte Signatur erneut zu versuchen, hieß, auf den Fehlertext von osslsigncode zu matchen, und der lautet auf diesem Pfad “Faild to decode CRL”. Dieses (le)? ist das ehrlichste Stück Code im Repo, eine Regex, die sich nach dem Tippfehler von jemand anderem verbiegt, damit die Prüfung sich überhaupt nicht verbiegen muss. Und die ganze Pipeline, die sie bewacht, kompiliert deinen EA. Gut macht sie ihn nicht. Wenn er sauber baut und dann dein Konto per Martingale in den Boden rammt, ist das dein Bier.
Der Code liegt hier: github.com/psyb0t/docker-mql-compiler. WTFPL, mach, was zum Teufel du willst.