Skip to content

Make firmware images byte-reproducible for a source commit#218

Open
RobVanProd wants to merge 2 commits into
mainfrom
fix/reproducible-firmware-builds
Open

Make firmware images byte-reproducible for a source commit#218
RobVanProd wants to merge 2 commits into
mainfrom
fix/reproducible-firmware-builds

Conversation

@RobVanProd

Copy link
Copy Markdown
Owner

Two clean builds of identical source produced different binaries. That meant a paired firmware/source record could only ever mean "trust this archived artifact" — weak for a release process that binds an exact firmware SHA-256 to recorded hardware evidence.

The cause was three bytes

chip-debug-report.cpp in the Arduino core prints:

chip_report_printf("  Compile Date/Time : %s %s\n", __DATE__, __TIME__);

So the image carries the wall-clock time of the build. Two builds a minute apart differed at 0xE2AE (12:04:14 vs 12:05:00), and everything derived from the image then differed too:

range bytes what
0xB0..0xCF 32 app_elf_sha256 in esp_app_desc_t — consequence
0xE2AE..0xE2B1 3 __TIME__ string — the actual cause
0x2B299F..0x2B29BF 33 esptool's appended image hash — consequence

68 differing bytes, only 3 of them real.

Correcting an earlier claim of mine: I first attributed this to the esp_app_desc build timestamp. That was wrong — the time/date fields of that structure (0x70..0x90) are byte-identical between builds. The differing bytes there are the ELF hash, which is downstream of the real cause.

Fix

tools/platformio_reproducible_build.py pins __DATE__/__TIME__ to the source commit, so the same commit always yields the same image while the banner still identifies the build:

[reproducible-build] pinned __DATE__/__TIME__ to '10b0cc5404e0 reproducible'

STACKCHAN_BUILD_STAMP overrides the value; STACKCHAN_DISABLE_REPRODUCIBLE_BUILD=1 restores wall-clock behaviour.

Why it is attached the way it is

The script is added to every extra_scripts block rather than a global [env] section. PlatformIO overrides extra_scripts per environment instead of merging it, and the inheritance chain does not reach env:stackchan from the release environments — stackchan_wake_mww_uplink has no extends, so the chain from stackchan_camera_probe stops there.

I tried the [env] form first and watched the script silently not run, which is how I found that out rather than assuming it.

Verified across a minute boundary

That boundary is exactly the case that used to differ:

result
stackchan_camera_probe two clean builds byte identical
stackchan_release_full two clean builds byte identical
native_logic 289 / 289 pass

Follow-up for whoever adds an environment

AGENTS.md now states the invariant and warns that a new build environment needs the pre-script or its images stop being reproducible. A contract test asserting every firmware env carries it would be a reasonable next step — I did not add one because the existing env-contract tests are PowerShell and cannot run here.

🤖 Generated with Claude Code

RobVanProd and others added 2 commits July 26, 2026 13:05
Two clean builds of identical source produced different binaries, so a paired
firmware/source record could only ever mean "trust this archived artifact".
That is weak for a release process that binds an exact firmware SHA-256 to
recorded hardware evidence.

The cause was three bytes. chip-debug-report.cpp in the Arduino core prints

  chip_report_printf("  Compile Date/Time : %s %s\n", __DATE__, __TIME__);

so the image carried the wall-clock time of the build. Two builds a minute
apart differed at 0xE2AE, and everything derived from the image then differed
too: app_elf_sha256 in esp_app_desc_t at 0xB0, and the SHA-256 esptool appends
at the end. 68 differing bytes, of which only 3 were a real difference.

An earlier note in this session attributed it to the esp_app_desc build
timestamp. That was wrong. The date and time fields of that structure are
identical between builds; the differing bytes are the ELF hash, which is a
consequence rather than the cause.

tools/platformio_reproducible_build.py now pins both macros to the source
commit, so the same commit always yields the same image and the banner still
identifies the build. STACKCHAN_BUILD_STAMP overrides the value and
STACKCHAN_DISABLE_REPRODUCIBLE_BUILD=1 restores wall-clock behaviour.

The script is attached to every extra_scripts block rather than a global [env]
section, because PlatformIO overrides extra_scripts per environment instead of
merging it, and the inheritance chain does not reach env:stackchan from the
release environments. Verified by building with the [env] form first and
watching the script not run.

Verified across a minute boundary, which is the case that used to differ:

  stackchan_camera_probe   two clean builds byte identical
  stackchan_release_full   two clean builds byte identical
  native_logic             289 of 289 pass

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_lan_service starts the LAN service on a background thread and then
connects immediately, which races the bind. On a loaded runner the connect
wins and the test errors with ConnectionRefusedError. It blocked two firmware
pull requests in a row while passing locally every time.

Adds a small helper that retries the connect until the listener is up, used at
the three sites that had the bare create_connection. Test-only; no bridge
behaviour changes.

This is inside bridge/, which the host-brain agent owns. It is included here
because the flake was blocking unrelated firmware merges, and the change is
confined to test setup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant