feat: multi-arch (amd64+arm64) release and audit hardening batch - #28
Conversation
Comprehensive code review remediation (audit 2026-09-29), F1–F10.
F1 — multi-arch: upstream simplex-chat publishes simplex-chat-ubuntu-24_04
for BOTH x86_64 and aarch64 at the pinned v7.0.2, so the "amd64 only / no ARM
binary is published" claim was false and suppressed a working configuration.
- Dockerfile: select asset + SHA256 per TARGETARCH for simplex-chat; gosu now
resolves gosu-${arch} instead of hardcoding gosu-amd64. Both digests come
from the v7.0.2 release notes. `test -s` guards the checksum file — an empty
grep result would make sha256sum -c succeed on zero entries.
- TARGETARCH falls back to uname -m so plain `docker build` still works.
- CI: PLATFORMS -> linux/amd64,linux/arm64; smoke job is a platform matrix
with QEMU for arm64, using the same cap_drop/cap_add/no-new-privileges set
and a longer healthcheck budget for emulation.
- CI: mirror to Docker Hub with `buildx imagetools create` instead of
pull+retag+push. A pull on an amd64 runner resolves only the amd64 manifest,
which would REPLACE Docker Hub's multi-arch index with a single-arch image
and break arm64 pulls. Added a step asserting both platforms exist on both
registries.
F2 — first-run bot detection checked /data/simplex_v1_chat.db, a path that can
never exist: upstream -d/--database is a FILE PREFIX (the CLI appends
_chat.db itself), so -d /data/simplex yields /data/simplex_chat.db. The check
was always true, passing --create-bot-display-name on every start. Latent only
because Core.hs gates creation on the DB having no active user — now it checks
the real path instead of relying on that undocumented upstream detail.
F3/F4 — install-websockets.sh patched adapter.py with `sed -i`, but the
adapter was refactored to a _send_cmd() helper, so both the "already fixed"
and "needs patching" patterns matched nothing: the script silently no-op'd,
printed a warning, and exited 0. Removed the patching entirely. The check is
now read-only and structural, and exits non-zero on an unrecognised adapter
instead of warning-and-continuing.
F5 — image redistributed AGPL-3.0 binaries (simplex-chat, simplexmq) under a
GPL-3.0 OCI label. Label corrected to AGPL-3.0; README now lists bundled
components with licenses and source links per AGPL-3.0 s13.
F6 — documented for follow-up: repo has six git tags but zero GitHub Releases,
so the v1.2.0 "release" has no page. Not cut here; needs a tag decision.
F7/F8 — Unraid template changelog updated; README "verified on v1.0.1" recipes
now state that scope instead of implying current-image verification.
F9 — SIMPLEX_TOR is documented honestly: it passes -x, which selects a local
SOCKS5 proxy at :9050; it does not configure Tor, and onion-only routing needs
--socks-mode which this image does not set. Also resolved the Hermes
minimum-version contradiction (0.16.0 vs 0.20.0) — main now requires 0.20.0+
and verifies rather than patches.
F10 — removed stray __pycache__.
Also: SIMPLEX_STARTUP_TIMEOUT (default 15s, validated) so slow/emulated ARM
hosts don't fail the 15s window; the wait loop now exits early if the daemon
dies instead of burning the full timeout.
Verification: shellcheck clean on both scripts; py_compile OK; workflow and
compose YAML parse; template XML parses; arch-selection logic replicated and
tested for amd64/arm64/empty-TARGETARCH and cross-checked against the v7.0.2
release notes; both asset URLs return 200; gosu-arm64 checksum line present.
Not verified: docker build/run (no Docker in this environment) — CI smoke is
the real gate.
CI caught this on PR #28: both smoke jobs failed with "simplex-chat exited before opening port 5225" on a container that was starting perfectly normally. Root cause, confirmed by runner timestamps: the check fired 0.029ms after `PID: 40` was logged — the loop tests the port, not the PID, so the very first iteration ran the liveness test before the Haskell daemon had any opportunity to bind. `$!` is the PID of the `gosu` wrapper, which is not a reliable proxy for the daemon's liveness across the gosu -> sh -> simplex-chat exec chain, so `kill -0` can report "dead" while the daemon is perfectly healthy. The original design — gate on the port, poll until the timeout — was correct and is restored. Only the timeout value and the log tail changed. A comment records why the early-exit must not be reintroduced. Also set `-o pipefail` on the two multi-arch RUN blocks (hadolint DL4006), which the per-arch checksum pipeline needs anyway: without it a failure in `grep` would be masked by the downstream `sed`.
Second CI failure on PR #28: both smoke jobs failed to build with "/bin/sh: 1: set: Illegal option -o pipefail". Root cause: Ubuntu's /bin/sh is dash, which has no `set -o pipefail`. The pipefail I added to silence hadolint DL4006 broke the build outright. The original `set -eux` was correct; hadolint's suggestion is wrong for this base image. Rather than leave DL4006 unsatisfied, removed the pipe entirely, which is strictly stronger than pipefail would have been. Verified under real dash: old: `grep ... | sed ... > f` with NO match -> exits 0, f is 0 bytes, so `sha256sum -c f` "verifies" nothing and the build proceeds new: `grep ... > f; test -s f` with NO match -> exits 1, build aborts The gosu checksum step now greps to a file, asserts it is non-empty, then sed-rewrites the path. DL4006 is waived in .hadolint.yaml with the reason recorded so the next person does not re-add pipefail.
CI green — arm64 verified end to endRun The arm64 job is the real acceptance test for F1, and it passed with the right artifacts:
Two bugs CI caught that my static checks did notWorth recording, because both are the kind of thing that reads as correct in review: 1. Unsound daemon early-exit ( 2. Rather than leave the lint warning unsatisfied, I removed the pipe instead — which is stronger than pipefail. Verified under real dash:
DL4006 is now waived in Both regressions were introduced by me in the first commit and were caught only by the smoke job — which is the strongest argument for keeping that job exactly as strict as it is. Noted in the PR body rather than quietly fixed. Remaining, unchanged
|
Found during post-merge verification of #28 — the merged image did NOT carry the F5 license fix. ## The bug The published image had `org.opencontainers.image.licenses = GPL-3.0` even though the Dockerfile says AGPL-3.0. `docker/metadata-action` infers the label from the GitHub repo's license and OVERRIDES the Dockerfile LABEL via its `labels:` input. Every other OCI label survived correctly; only `licenses` was replaced, because it is the one metadata-action derives automatically. So the F5 remediation from #28 was silently reverted in the artifact users actually pull, while the README and Dockerfile both claimed AGPL-3.0 — the exact mismatch finding F5 was about, reintroduced by the publishing path. Fix: declare it explicitly in the metadata-action `labels:` block, which wins over both the inference and the Dockerfile. ## Also: stop the build job going vacuously green on PRs Verified from run 36907154998 that on a `pull_request` event every publish step is skipped: ``` 5 skipped Log in to GHCR 6 success Build and push to GHCR <- push:false, published nothing 7 skipped Log in to Docker Hub 8 skipped Mirror tags to Docker Hub 9 skipped Verify Docker Hub manifest platforms ``` The job reports success having published nothing, so a green `build` badge on a PR is not evidence that registry publishing works. A "Report publishing scope" step now announces this in the run summary, so the signal is honest about what it did and did not do. Refs #28
…#29) Found during post-merge verification of #28 — the merged image did NOT carry the F5 license fix. ## The bug The published image had `org.opencontainers.image.licenses = GPL-3.0` even though the Dockerfile says AGPL-3.0. `docker/metadata-action` infers the label from the GitHub repo's license and OVERRIDES the Dockerfile LABEL via its `labels:` input. Every other OCI label survived correctly; only `licenses` was replaced, because it is the one metadata-action derives automatically. So the F5 remediation from #28 was silently reverted in the artifact users actually pull, while the README and Dockerfile both claimed AGPL-3.0 — the exact mismatch finding F5 was about, reintroduced by the publishing path. Fix: declare it explicitly in the metadata-action `labels:` block, which wins over both the inference and the Dockerfile. ## Also: stop the build job going vacuously green on PRs Verified from run 36907154998 that on a `pull_request` event every publish step is skipped: ``` 5 skipped Log in to GHCR 6 success Build and push to GHCR <- push:false, published nothing 7 skipped Log in to Docker Hub 8 skipped Mirror tags to Docker Hub 9 skipped Verify Docker Hub manifest platforms ``` The job reports success having published nothing, so a green `build` badge on a PR is not evidence that registry publishing works. A "Report publishing scope" step now announces this in the run summary, so the signal is honest about what it did and did not do. Refs #28
Changes
Remediation for the comprehensive code review of 2026-09-29 (findings F1–F10).
Full report:
projects/simplex-bridge-code-review-2026-09-29.mdin the project vault.F1 — Multi-arch (the headline change)
Upstream simplex-chat publishes
simplex-chat-ubuntu-24_04-aarch64at thepinned v7.0.2 (verified against the v7.0.2 release notes, which carry a SHA256
per arch). The "no ARM binary is published" claim was therefore false, and it
suppressed a working configuration for anyone on Apple Silicon or ARM Unraid.
Dockerfileselects asset and SHA256 perTARGETARCHfor simplex-chat;gosu now resolves
gosu-${arch}instead of hardcodinggosu-amd64.TARGETARCHfalls back touname -m, so plaindocker buildstill works.test -sbeforesha256sum -con the gosu checksum file — an emptygrep result would otherwise make the verification succeed on zero entries.
PLATFORMS→linux/amd64,linux/arm64; the smoke job is now a platformmatrix using QEMU for arm64, with the same
cap_drop: ALL+CHOWN/SETUID/SETGID+no-new-privilegesset and alonger healthcheck budget for emulation.
buildx imagetools create. The previouspull → retag → push would, on an amd64 runner, resolve only the amd64
manifest and replace Docker Hub's multi-arch index with a single-arch
image — arm64 pulls would then fail with "no matching manifest". A new
step asserts both platforms are present on both registries.
F2 — First-run detection checked a path that cannot exist
-d/--databaseis a file prefix upstream (the CLI appends_chat.dbitself), so
-d /data/simplexcreates/data/simplex_chat.db. The entrypointwas checking
/data/simplex_v1_chat.db, so the first-run test was always trueand
--create-bot-display-namewas passed on every start. This was latent —Core.hsgates profile creation on the DB having no active user — but it nowchecks the real path instead of relying on an undocumented upstream detail.
F3/F4 —
install-websockets.shno longer patchesThe script
sed -i'dadapter.pyto work around hermes-agent#46265, but theadapter was refactored to a
_send_cmd()helper. Both the "already fixed" and"needs patching" patterns matched nothing: the script silently no-op'd,
printed
⚠ patch may not be complete (0/2), and exited 0.The patching is removed. The check is now read-only and structural, and
exits non-zero on an unrecognised adapter rather than warning and
continuing. A bad
sed -iedit into site-packages could have raised aSyntaxErrorthat breaks the gateway's plugin import outright.F5 — License metadata corrected
The image redistributes AGPL-3.0 binaries (simplex-chat, simplexmq) but was
labelled GPL-3.0. The OCI label is now
AGPL-3.0, and the README carries abundled-third-party-components table with licenses and source links, per
AGPL-3.0 §13. The repo's own source stays GPL-3.0 — only the label and
disclosure changed.
F6 — GitHub Releases
Six git tags, zero release objects.
v1.2.0is called "the latest release" inthe README but has no release page or notes. Left for a tag decision rather
than cut unilaterally here.
F7/F8/F9/F10 — Docs and hygiene
<Changes>updated with a 2026.09.29 entry.than implying verification against the current image.
SIMPLEX_TORdocumented honestly: it passes-x, which selects a localSOCKS5 proxy at
:9050. It does not configure Tor, and onion-only routingneeds
--socks-mode, which this image does not set. Worth a live test ifyou rely on it.
mainrequires 0.20.0+ and verifies ratherthan patches (the 0.16.0-vs-0.20.0 contradiction is gone).
SIMPLEX_STARTUP_TIMEOUT(default 15s, input-validated) so slow oremulated ARM hosts do not fail the hardcoded 15s window.
full timeout.
__pycache__.Verification
shellcheck entrypoint.sh install-websockets.sh— cleanpython3 -m py_compile healthcheck.py— OKamd64,arm64, andempty
TARGETARCH; outputs cross-checked against the v7.0.2 releasenotes — both asset names and both digests match upstream
gosu-arm64checksum line present in gosu 1.19SHA256SUMS; theDockerfile's grep+sed rewrite produces a valid
sha256sum -clinethis host (correctly identifies it as fixed)
docker build/ container run — not executed (no Docker available inthe authoring environment). The arm64 QEMU smoke job is the real gate;
please treat that job as the acceptance test for F1.
Reviewer notes
deliberately unchanged — they are correct, and the smoke job is what caught
the v1.1.0 abort in the first place.
docker-compose.ymlstill pins the v1.2.0 digest on purpose; it isamd64-only. Re-pin after the next release is cut and verified.
checksum, and entrypoint all work end to end — but emulation is not a
substitute for a run on real ARM hardware.