Voilà les lignes pour lesquelles tout le projet existe. Tout le reste de docker-mql-compiler sert à les préparer, ou à ranger derrière elles:
# 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 || trueÇa, c’est MetaEditor de MetaQuotes, le seul truc sur cette planète qui transforme un .mq5 en .ex5, une GUI Windows qui n’a jamais entendu parler de Linux, lancée par un script bash dans un conteneur Wine avec Xvfb comme faux écran que personne ne regarde. Regarde le || true. Le code de sortie part à la poubelle exprès, parce qu’il ment. Mon éditeur, mes agents et ma CI vivent tous sur Linux, et j’en avais ras le cul que quelqu’un aille tripoter une machine Windows pour cliquer sur “Compile” et recopier à la main les erreurs d’un panneau de log. Donc je vais lire entrypoint.sh dans l’ordre où il tourne: du premier téléchargement jusqu’à la ligne JSON qu’un agent lit à la fin.
Un peu de contexte d’abord. mt5-httpapi a déjà une route POST /compile, du source MQL5 en JSON à l’entrée, un .ex5 en base64 à la sortie. Sauf qu’elle vit dans une VM Windows complète sous KVM qui fait tourner des terminaux live et trade du vrai fric, qu’elle ne connaît que MQL5, qu’elle refuse les includes avec des chemins absolus ou des .., et qu’il faut d’abord uploader les bibliothèques partagées dans son arbre de compilation. Celui-ci compile un répertoire de code MT4 et MT5 sur un laptop ou un runner GitHub sans démarrer Windows du tout.
verify_installer: signé par MetaQuotes ou refusé
L’image, c’est Debian trixie avec Wine (64 bits plus la moitié 32 bits, parce que l’antique MetaEditor de MT4 est du code Windows 32 bits) et pas un seul octet de MetaQuotes. MetaTrader n’est pas à moi, je n’ai pas le droit de le redistribuer, donc le premier lancement télécharge les installeurs (ceux de RoboForex par défaut, à remplacer avec MQL_COMPILER_MT4_URL et MQL_COMPILER_MT5_URL). Il ne récupère que ce dont le lancement a besoin: que des fichiers .mq5, et seul MT5 est installé. Une minute et demie environ la première fois, quelques secondes ensuite.
Avant que wine ne touche un installeur, osslsigncode vérifie sa signature Authenticode contre le bundle de CA de l’image, puis ceci vérifie qui l’a vraiment signé:
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 vaut MetaQuotes. Seul le certificat du signataire compte, pas la chaîne ni les certificats d’horodatage qui traînent dans le même rapport. Une signature valide de quelqu’un d’autre, c’est quand même un refus. Si le serveur de révocation de la CA est en rade, verify_installer réessaie trois fois, puis vérifie l’empreinte, la chaîne et le signataire sans la consultation de révocation, et logue un warning avec reason=crl_unreachable pour que tu saches que c’est arrivé.
unpack_stdlib: démarrer le terminal une fois pour que Trade.mqh existe
L’installeur ne pose pas la bibliothèque standard sur le disque. C’est le terminal qui la décompresse la première fois qu’il démarre. Installe MetaEditor sous Wine, saute cette étape, et chaque #include <Trade/Trade.mqh> se plante. Donc le setup lance chaque terminal en headless, en mode portable, et attend le fichier témoin:
(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))
doneLe témoin, c’est Include/Trade/Trade.mqh pour MT5 et stdlib.mqh pour MT4. Dès qu’il apparaît, wineserver -k tue le terminal, qui ne se ferme jamais tout seul. Les terminaux installés sont ensuite déménagés hors de Program Files vers de simples répertoires portables, drive_c/terminals/mt4 et drive_c/terminals/mt5, dans le volume Docker mql-compiler-cache. Environ 2 GB, un sous-répertoire par ID utilisateur. Le conteneur tourne avec ton UID, donc les fichiers compilés t’appartiennent, pas à root.
build_merged_root: quatre couches d’include, la tienne au-dessus
MetaEditor lit les #include <...> depuis un seul et unique répertoire Include. Il t’en faut au moins deux, la bibliothèque standard de MetaQuotes et tes propres headers. C’est cette fonction qui rend l’outil vraiment utilisable:
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"
doneLis-la de haut en bas et tu as l’ordre des couches:
- L’
Includestandard du terminal (Trade/,Arrays/,stdlib.mqh, tout le bordel). - Chaque répertoire
-I, dans l’ordre où tu les as donnés. - Ton propre
Include.--remove-destinationveut dire qu’en cas de conflit c’est la couche la plus tardive qui gagne, et la tienne passe en dernier. UnTrade/Trade.mqhpatché dans ton arbre? C’est le tien qui est compilé. - Des liens vers le reste de ton répertoire
MQL4/MQL5(Files,Imageset compagnie), pour que#resource "\\Files\\data.txt"se résolve.
Tout ça, ce sont des symlinks (cp -rs) dans une zone de staging, construite une fois par plateforme et par répertoire utilisateur à chaque passe. Rien n’est écrit dans tes répertoires et tout disparaît à la fin du lancement. Quel répertoire MQL4 ou MQL5 compte comme le tien? Simplement le plus proche au-dessus du fichier source. L’extension seule choisit le compilateur, .mq4 vers le metaeditor.exe de MT4, .mq5 vers le MetaEditor64.exe de MT5.
stage_source: un lien pour contourner les espaces
Ton terminal vit dans MT5 - SomeBroker? Le /compile et le /inc de MetaEditor s’étouffent tous les deux sur les espaces. Donc une source avec des espaces dans son chemin est compilée à travers un lien:
[[ "$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"}"Le lien pointe vers tout le répertoire MQL4/MQL5, pas vers le fichier, donc les includes en "../" qu’il contient se résolvent toujours. C’est aussi pour ça que la racine d’include fusionnée vit dans la zone de staging: le répertoire d’un terminal monté a souvent des espaces dans son nom.
compile_one: d’abord supprimer, ensuite ne pas croire le code de sortie
Juste avant l’appel à wine du début de cet article, il y a ça:
# 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"Le .ex4/.ex5 qui traîne là après le lancement sort forcément de ce lancement. Pas de vieux binaire qui se fait passer pour du frais après que MetaEditor s’est vautré. Les fichiers se compilent un par un, jamais en parallèle, parce que deux MetaEditor sous Wine en même temps, c’est comme ça qu’on invoque des démons.
Après l’appel, le verdict vient du log, pas du code de sortie:
result="$(grep -E '^Result: ' <<<"$lines" | tail -n 1)" || result=""En ligne de commande, MetaEditor renvoie en général 1 quand tout s’est bien passé. Branche ça naïvement dans ta CI et tu obtiens un pipeline qui échoue quand ça réussit. La ligne Result: porte le nombre d’erreurs et de warnings, deux appels à sed les extraient, et --werror transforme les warnings en échec. Pas de ligne Result: du tout, ça veut dire que MetaEditor a planté ou n’a rien écrit, et l’enregistrement embarque alors ses lignes de log brutes au lieu de faire semblant.
read_log: les chemins Windows ramenés vers les tiens
MetaEditor écrit son log en UTF-16, avec des chemins Windows qui pointent dans une zone de staging que tu n’as jamais demandée. read_log le fait passer par iconv, retransforme Z:\ en /, et résout chaque chemin de staging vers le vrai fichier qui se cache derrière avec readlink. Ton header pour un header à toi, celui de la toolchain pour un header standard, et le répertoire courant est retiré du début. C’est du remplacement de chaînes en bash pur, pas du sed, donc aucun caractère dans un chemin ne peut le casser. Ce que ça rapporte: une erreur dans un header pointe vers le header, pas vers l’EA qui l’a inclus.
parse_messages: une regex, un enregistrement JSON
Chaque ligne d’erreur et de warning du log ressemble à path(line,column) : error 256: text, et un seul capture de jq les transforme en objets:
| capture("^(?<file>.*)\\((?<line>[0-9]+),(?<column>[0-9]+)\\) : (?<severity>error|warning) (?<code>[0-9]+): (?<text>.*)$")?Sans --text, stdout est du JSON Lines: un enregistrement file par source à mesure qu’elle se termine, puis un summary. La progression part sur stderr, elle aussi en lignes JSON, donc 2>/dev/null ne laisse que les résultats.
{"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}Chaque message est un {severity, file, line, column, code, text}, exactement ce dont un LLM qui écrit des EA a besoin, au lieu d’une capture d’écran d’un panneau de log. --format gnu rend le même enregistrement sous forme de lignes path:line:column: error: text [code] sur lesquelles VS Code, vim et emacs sautent directement. Les codes de sortie sont chiants exprès: 0 tout a compilé, 1 au moins un a échoué, 2 mauvais arguments ou un setup qui n’a pas pu s’installer.
mql-compiler 2>/dev/null | jq -r 'select(.status == "failed") | .source'C’est tout le rapport “qu’est-ce qui a pété”. Le repo livre aussi un skill pour agents, publié sur ClawHub à chaque release, qui apprend à Claude Code, Codex, OpenClaw et peen à installer la commande, compiler et vraiment lire ces enregistrements, au lieu d’halluciner que l’EA a compilé nickel.
Le wrapper qui appelle tout ça
Rien de tout ce qui précède ne tourne tant que quelque chose ne démarre pas le conteneur. Il te faut Docker sur une machine x86-64. Pas d’image ARM, parce que le MetaEditor de MT4 a besoin de la moitié i386 de Wine, donc Apple Silicon reste sur le carreau, sauf si Docker t’émule de l’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 -- --systemL’installeur épingle l’image de release la plus récente et écrit un seul fichier, la commande mql-compiler, qui n’est qu’un mince docker run. Relance-le pour mettre à jour, --rolling épingle :latest à la place, --uninstall supprime la commande et son image. Le volume de cache reste, parce que tous les utilisateurs de la machine le partagent.
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 installersRange tes sources sous un répertoire nommé MQL4 ou MQL5, même structure que le répertoire de données d’un terminal, les headers dans son Include. Les arguments peuvent être des fichiers ou des répertoires, relatifs ou absolus, n’importe où sur la machine, plateformes mélangées. Le wrapper monte chaque répertoire nécessaire au même chemin dans le conteneur et démarre un seul conteneur pour tout le lancement. Lance-le sans argument depuis / et il refuse, parce que scanner tout ton disque à la recherche d’EA, c’est une idée à la con.
–watch
--watch garde le conteneur ouvert, compile, puis se pose sur inotifywait et refait une passe complète dès qu’un .mq4, .mq5 ou .mqh change sous tes chemins ou dans un répertoire -I. Entre deux passes, il lâche le verrou du cache, donc une compilation ponctuelle depuis un autre terminal passe quand même à son tour. Ctrl-C sort avec 130, proprement.
–mt4-terminal and –mt5-terminal
Le terminal de ton broker tourne sur un build précis de MetaEditor et c’est exactement celui-là que tu veux? Pointe ces options vers un répertoire de terminal portable déjà sur le disque et compile_one lance son MetaEditor contre sa bibliothèque standard. Cette plateforme n’est jamais téléchargée, le terminal est monté en lecture seule, et si tu oublies l’option la fois suivante, tu reviens sur le build en cache. Le terminal doit avoir été démarré une fois pour avoir décompressé ses headers, sinon c’est exit 2 avant que quoi que ce soit ne compile. Comme les terminaux en cache sont déménagés dans la même forme portable, il n’y a aucune différence entre les miens et le tien.
Le cache garde pour toujours le premier build de MetaEditor qu’il a installé. --version te dit sur quels builds tu tournes, --update réinstalle depuis les installeurs les plus récents, et si ça foire, l’ancienne toolchain revient intacte.
Le même entrypoint sur un runner GitHub
- uses: actions/checkout@v6
- uses: psyb0t/docker-mql-compiler@master
with:
paths: |
MQL5/Experts
MQL4/Indicators/Bands.mq4Ces enregistrements JSON, c’est ce que lit l’Action. Les erreurs de compilation font échouer le job et atterrissent en annotations sur les lignes exactes du source dans la PR. Les inputs sont paths, include, werror, image, cache et cache-dir. Avec cache activé (par défaut), la toolchain de 2 GB reste dans le cache d’Actions, donc seul le premier lancement se mange l’installation de 90 secondes. Les outputs sont results (le fichier JSON Lines), total, compiled et failed, et le résumé du job reçoit un tableau de tous les fichiers. Un EA qui ne compile pas n’atteint plus jamais main, et personne n’a touché à Windows pour le découvrir.
La regex qui a eu droit à son propre commit de fix
La première release est sortie sans elle. Le commit de fix suivant dans l’historique existe pour cette seule ligne:
# 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"Sur les runners de GitHub, la liste de révocation Sectigo récupérée par osslsigncode arrivait tronquée, et chaque premier lancement ratait la vérification de signature sur un installeur MetaQuotes parfaitement sain, y compris le job de test de la v0.1.0. Réessayer ça sans réessayer aussi une signature vraiment pourrie voulait dire matcher le texte d’erreur d’osslsigncode, qui sur ce chemin dit “Faild to decode CRL”. Ce (le)?, c’est le bout de code le plus honnête du repo, une regex qui se plie à la faute de frappe de quelqu’un d’autre pour que la vérification, elle, ne plie pas d’un poil. Et tout le pipeline qu’elle garde compile ton EA. Il ne le rend pas bon. Si ton EA compile proprement et qu’ensuite il martingale ton compte jusqu’au fond du trou, ça, c’est pour ta gueule.
Le code est là: github.com/psyb0t/docker-mql-compiler. WTFPL, fais ce que tu veux, putain.