Skip to content

ci: speed up Docker builds with native arm64 runners and cargo-chef caching - #710

Open
Jon-Becker wants to merge 1 commit into
mainfrom
fix/docker-build-caching
Open

ci: speed up Docker builds with native arm64 runners and cargo-chef caching#710
Jon-Becker wants to merge 1 commit into
mainfrom
fix/docker-build-caching

Conversation

@Jon-Becker

@Jon-Becker Jon-Becker commented Jul 22, 2026

Copy link
Copy Markdown
Owner

What changed? Why?

Docker builds were taking 60 to 66 minutes because the linux/arm64 build ran under QEMU, the GitHub Actions cache repeatedly failed with not_found, and source changes invalidated the full Rust compile layer.

This PR builds each architecture on a native runner, uses platform-scoped GHCR registry caches, separates Rust dependency compilation with cargo-chef, removes duplicated release flags, narrows Docker pushes to main and tags, and cancels superseded runs. The runtime image, entrypoint, and amd64/arm64 output are unchanged.

Notes to reviewers

  • The first build after merging will populate the new registry caches.
  • ubuntu-24.04-arm is required for the native arm64 job.
  • The Dockerfile still uses the release profile from Cargo.toml: fat LTO, one codegen unit, and non-incremental compilation.
  • The local environment has no Docker daemon, so no local image build was possible.

How has it been tested?

  • actionlint .github/workflows/docker.yml: 0 findings
  • python3 YAML parse: passed; triggers and concurrency verified
  • cargo metadata: passed; heimdall binary target confirmed
  • Inspected Cargo.toml release profile: optimization settings preserved
  • Docker image build: not run locally because the Docker daemon is unavailable

…aching

- Build each platform on its native runner (arm64 on ubuntu-24.04-arm)
  instead of under QEMU emulation, then merge into a manifest list. This
  removes the ~1h arm64 emulation cost from every build.
- Split dependency compilation from source compilation via cargo-chef so
  source-only edits reuse the cached dependency layer, and cache layers to
  a platform-scoped GHCR registry ref instead of the fragile type=gha cache.
- Drop redundant RUSTFLAGS/CARGO_PROFILE_RELEASE_LTO overrides; fat LTO and
  codegen-units=1 are already defined in Cargo.toml's [profile.release].
- Only build on main, tags, and manual dispatch (not every branch push), and
  cancel superseded runs for the same ref via a concurrency group.
- Keep non-build assets out of the build context via .dockerignore.
@github-actions

Copy link
Copy Markdown
Contributor

❌ Eval Report for c46459e

Test Case CFG Decompilation
NestedMapping 100 5
TransientStorage 100 20
SimpleStorage 100 88
SimpleLoop 85 5
Mapping 100 92
WhileLoop 68 12
NestedLoop 52 20
Events 100 62
NestedMappings 95 35
WETH9 100 74
Average 90 41
⚠️ 7 eval(s) scoring <70%

NestedMapping (CFG: 100, Decompilation: 5)

Decompilation

{
  "score": 5,
  "summary": "The decompilation fails to capture the contract's fundamental behavior. The original writes to and reads from nested mappings (allowances, grid, deepNested) via setAllowance/setGrid/setDeepNested/getAllowance plus auto-generated public getters. The decompiled output contains none of these storage operations: every function is incorrectly marked `pure` and reduced to trivial no-op checks like `require(arg0 == arg0)`, with no SSTORE/SLOAD, no nested slot hashing, no return values, and no correspondence to the original function selectors.",
  "differences": [
    "All storage writes are missing: setAllowance/setGrid/setDeepNested perform nested-mapping SSTOREs in the original, but the decompiled functions perform no storage writes at all.",
    "All storage reads are missing: getAllowance and the public mapping getters read nested-mapping slots (SLOAD) and return values, but the decompiled functions have no reads and no return values.",
    "Function mutability is wrong: original functions are state-mutating (public) or view, but every decompiled function is marked `pure`.",
    "Nested mapping slot computation (keccak-based key hashing for double/triple nesting) is entirely absent.",
    "Function selectors do not correspond to any original function; the recovered functions appear to be argument-type probes rather than the actual contract logic.",
    "Return values (uint256 from getAllowance and getters) are dropped entirely."
  ]
}

TransientStorage (CFG: 100, Decompilation: 20)

Decompilation

{
  "score": 20,
  "summary": "The decompilation fails to preserve most of the contract's behavior. Five of the six functions (incrementCounter, lock, unlock, getCounter, isLocked) are collapsed into meaningless public constants rather than functions, losing all their transient-storage read/write and increment logic. Only setTempOwner is emitted as a function, and even it is mislabeled 'pure' despite writing transient storage. Getters return hardcoded constants instead of reading state.",
  "differences": [
    "incrementCounter's logic (counter = counter + 1) is entirely lost — represented as an empty constant 'incrementCounter' with no increment or transient read/write.",
    "lock() and unlock() logic (setting the transient 'locked' flag to true/false) is lost — both reduced to constants with no store operation.",
    "getCounter() returns a hardcoded constant 1 instead of reading the transient counter slot (TLOAD).",
    "isLocked() returns a hardcoded constant true instead of reading the transient locked slot.",
    "setTempOwner is incorrectly marked 'pure' even though it writes transient storage (TSTORE), misrepresenting its mutability.",
    "Overall control flow and dispatch to distinct state-changing functions is not reconstructed; only one of six functions retains recognizable logic."
  ]
}

SimpleLoop (CFG: 85, Decompilation: 5)

Decompilation

{
  "score": 5,
  "summary": "The decompilation completely fails to capture the contract's core behavior. The original loops `loops` times, incrementing the storage variable `number` each iteration. The decompiled output contains no loop, no increment, and no storage write, and incorrectly marks the function as `view`. The require statements are nonsensical fragments that do not correspond to any original logic.",
  "differences": [
    "The for-loop control flow is entirely missing; no iteration structure is present",
    "The state write (number++ / SSTORE to `number`) is absent; function is incorrectly marked `view` instead of state-mutating",
    "The increment arithmetic (number = number + 1 per iteration) is not represented",
    "Spurious require statements (require(arg0 == arg0), require(!0 < arg0), require(number - 0xff...ff)) do not correspond to any original operation and are logically meaningless",
    "The loop bound comparison (i < loops) is not correctly captured as a loop condition"
  ]
}

WhileLoop (CFG: 68, Decompilation: 12)

Decompilation

{
  "score": 12,
  "summary": "The decompilation fails to capture the fundamental behavior of the contract. The original loops `loops` times incrementing the storage variable `number`, but the decompiled output contains only meaningless require statements, omits the loop entirely, performs no storage write, and incorrectly marks the function as `view`.",
  "differences": [
    "The while loop control flow is completely missing; no iteration structure is represented",
    "The storage write to `number` (number = number + 1) is absent; state mutation is lost",
    "Function is incorrectly marked `view`, whereas the original mutates contract state",
    "The require statements (arg0 == arg0, !0 < arg0, !number > number + 1) are tautological/nonsensical and do not correspond to any original logic",
    "The increment of loop counter `i` and the loop termination condition (i < loops) are not represented"
  ]
}

CFG

{
  "score": 68,
  "summary": "The CFG captures the full function-dispatch structure, both function bodies, all revert/require paths, and the while-loop's condition, body-entry, and exit. However, the loop's back-edge is missing: the body block (node 8) only reaches the overflow-panic revert (node 9), and the no-overflow continuation that increments i and jumps back to the condition check is not represented. The loop therefore never iterates in the CFG, which is the core control-flow feature of this contract.",
  "missing_paths": [
    "Loop back-edge: after executing the body (number++, i++), control returns to the while condition check (0x77) to re-evaluate i < loops. In the CFG, node 8 (body) has only a single successor to the overflow-panic revert (node 9); there is no edge back to the condition node (node 7).",
    "Loop body continuation block (bytecode at ~0x0193 onward: the i = i + 1 increment and the JUMP back to the condition) is not present as a node in the graph, so the normal (non-overflow) exit of the addition routine is unrepresented."
  ],
  "extra_paths": [
    "CALLVALUE (non-payable) revert path — node 0 -> node 1 (compiler-added)",
    "CALLDATASIZE < 4 selector-length revert — node 2 -> node 14 (compiler dispatch)",
    "Unknown-selector fallthrough revert — node 12 -> node 14 (compiler dispatch)",
    "Calldata SLT/offset sanity-check revert in the loop argument decoder — node 4 -> node 11 (compiler-added)",
    "Calldata word bounds-check revert in the uint256 decoder — node 5 -> node 6 (compiler-added)",
    "Arithmetic overflow (0x4e487b71 Panic(0x11)) revert on number + 1 — node 8 -> node 9 (compiler-added overflow check)"
  ],
  "observations": [
    "Function dispatch is fully captured: selector comparisons for loop() (0x0b7d796e) and number() (0x8381f58a) each produce a match branch and a fallthrough branch.",
    "Both function exit points are present: loop() reaches STOP (node 10) and the number() getter reaches RETURN (node 13).",
    "The while-condition branch itself is correctly modeled: node 7 branches to the body (node 8) when i < loops and to the exit (node 10) when the condition fails.",
    "All error/revert leaves (nodes 1, 6, 9, 11, 14) are terminal REVERTs and are reached, so the require/panic paths are represented even though most are compiler-added.",
    "The single substantive gap is the loop iteration edge; without it the graph is a DAG and cannot express repeated execution of the loop body, which understates the reachable state space for a loop-centric contract."
  ]
}

NestedLoop (CFG: 52, Decompilation: 20)

Decompilation

{
  "score": 20,
  "summary": "The decompilation fails to capture the fundamental behavior of the contract. The original performs a nested loop that increments the storage variable `number` by 1 on each inner iteration (loops*loops writes). The decompiled output contains neither the loop control flow nor the storage write, mutability is wrong (marked `view` when the function mutates state), and the increment appears only as a nonsensical require condition rather than a state update.",
  "differences": [
    "Storage write missing: the original writes `number += 1` repeatedly to storage; the decompiled code performs no SSTORE and is marked `view`, so all state changes are lost",
    "Control flow missing: the two nested for-loops are not represented; there are no loop constructs, only flat require statements",
    "Mutability incorrect: function is decompiled as `public view`, but the original mutates state and is non-view",
    "Increment logic mangled: `number + 1` appears only inside a `require(!number > (number + 0x01))` condition rather than as an actual assignment/accumulation",
    "The require statements (`require(arg0 == arg0)`, `require(!0 < arg0)`) do not correspond to any logic or revert condition in the original and misrepresent the loop bound checks"
  ]
}

CFG

{
  "score": 52,
  "summary": "The CFG captures both function entry points (loop and the auto-generated number getter), the dispatcher/selector branches, all revert paths, and the entry + condition-test of both the outer and inner for-loops. However, it fails to represent the defining feature of a nested loop: neither loop forms a cycle. The inner-loop exit block is entirely absent and there are no back-edges (increment -> re-test) for either loop, so the graph is essentially acyclic where the source has two nested cycles.",
  "missing_paths": [
    "Inner loop exit path: node 8 tests the inner condition (0x87 LT, JUMPI to 0xb1) but the target block 0xb1 (inner-loop exit leading to the outer i++ increment) is not present in the CFG.",
    "Inner loop back-edge: node 9 (the 'number += 1' body with j++) has no edge returning to the inner-loop condition head at 0x84; only the panic-revert successor (9->10) is shown, and the fall-through continuation at 0x01ac (post-overflow-check -> SSTORE -> j++ -> re-test) is missing.",
    "Outer loop back-edge: after the inner loop completes there is no edge performing the outer i++ increment and jumping back to the outer-loop condition head at 0x77; the only outer-loop successor captured is the exit (7->11).",
    "Loop increment blocks (i++ and j++) and their continuation after the checked-add are not represented as reachable nodes."
  ],
  "extra_paths": [
    "Overflow/Panic(0x11) check on 'number += 1' (node 9 -> node 10, the 0x4e487b71 panic revert) - compiler-added arithmetic check.",
    "CALLVALUE (non-payable) check reverting to node 1 - compiler-added.",
    "CALLDATASIZE < 4 short-calldata revert (node 2 -> node 15) - compiler-added dispatch guard.",
    "Calldata ABI-decode bounds/SLT check reverting via node 12 - compiler-added decoding logic.",
    "Signed/argument validation revert at node 6 - compiler-added decode guard."
  ],
  "observations": [
    "Both function entries are captured: 0x0b7d796e (loop) via node 3->4 and 0x8381f58a (number getter) via node 13->14.",
    "The two loop condition tests are present as branches (outer at 0x77/0x7a, inner at 0x84/0x87), and both bodies are entered (7->8, 8->9), so the loop *entry* structure is intact.",
    "The critical omission is the cyclic structure: the extracted graph is a near-tree. The inner loop's exit node (0xb1) is missing and no back-edges close either loop, so a reader could not tell from the CFG that this is a nested loop rather than two sequential straight-line conditionals.",
    "Because loops are the entire purpose of this 'NestedLoop' contract, the missing back-edges and inner exit constitute significant (not minor) control-flow loss, placing this in the 40-59 band."
  ]
}

Events (CFG: 100, Decompilation: 62)

Decompilation

{
  "score": 62,
  "summary": "Five of seven event-emitting functions are correctly preserved, including the multi-event function with its string literal intact. However, the two three-parameter functions (emitTransfer and emitApproval) failed to decompile: they emit no event and recover only one of three parameters, and the Approval event is not even declared. Since each function's sole purpose is emitting an event, this is a complete loss of behavior for those two functions.",
  "differences": [
    "emitTransfer (Unresolved_5687f2b8): the Transfer event emission is entirely missing and only 1 of 3 parameters was recovered; the function body contains only an ABI-decode require",
    "emitApproval (Unresolved_23de6651): the Approval event emission is entirely missing and only 1 of 3 parameters was recovered; the Approval event is not even declared in the decompiled contract",
    "emitLog (Unresolved_2e3c2a4d): the Log emission passes the raw argument/offset pseudo-values rather than clearly the decoded string message"
  ]
}

NestedMappings (CFG: 95, Decompilation: 35)

Decompilation

{
  "score": 35,
  "summary": "The decompilation only partially preserves the contract's behavior. The approve writer stores the value but loses the nested mapping key structure (msg.sender is dropped, only spender is used as the key). Both read paths (the explicit allowance getter and the auto-generated allowances getter) are reduced to trivial argument-validation stubs with no storage reads and no return values, so the core purpose of reading the nested mapping is entirely absent.",
  "differences": [
    "approve: the nested mapping write allowances[msg.sender][spender] = amount is flattened to a single-level store keyed only on spender (arg0); the msg.sender outer key of the nested mapping is not represented, so writes target the wrong slot",
    "allowance(owner, spender) view function: the storage read and return of allowances[owner][spender] is missing entirely; decompiled body only performs a no-op require and returns nothing (marked pure instead of view)",
    "allowances(address,address) auto-generated getter: the nested mapping read and return value are missing entirely; decompiled body only performs a no-op require",
    "State mutability is misrepresented: read functions that access storage are marked pure, and approve is marked payable rather than a plain state-changing function"
  ]
}

@github-actions

Copy link
Copy Markdown
Contributor

✅ Coverage Report for c46459e

Metric Value
Base branch 66.24%
PR branch 66.27%
Diff +0.04%

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark for c46459e

Click to view benchmark
Test Base PR %
heimdall_cfg/complex 16.5±0.72ms 14.8±1.04ms -10.30%
heimdall_cfg/simple 1376.4±12.72µs 1377.2±59.08µs +0.06%
heimdall_decoder/seaport 58.9±4.62µs 58.8±3.57µs -0.17%
heimdall_decoder/transfer 4.2±0.37µs 4.3±0.46µs +2.38%
heimdall_decoder/uniswap 16.2±1.45µs 16.0±1.14µs -1.23%
heimdall_decompiler/abi_complex 60.0±1.25ms 56.3±0.41ms -6.17%
heimdall_decompiler/abi_simple 1468.0±33.77µs 1446.9±23.91µs -1.44%
heimdall_decompiler/sol_complex 84.0±1.16ms 80.0±1.51ms -4.76%
heimdall_decompiler/sol_simple 2.2±0.02ms 2.2±0.05ms 0.00%
heimdall_decompiler/yul_complex 64.4±0.81ms 63.2±0.80ms -1.86%
heimdall_decompiler/yul_simple 1628.5±19.21µs 1601.9±18.07µs -1.63%
heimdall_disassembler/complex 1302.2±104.47µs 1298.9±106.72µs -0.25%
heimdall_disassembler/simple 64.3±5.96µs 64.7±7.68µs +0.62%
heimdall_vm/erc20_transfer 268.2±24.62µs 267.0±21.29µs -0.45%
heimdall_vm/fib 857.5±7.59µs 854.4±5.88µs -0.36%
heimdall_vm/ten_thousand_hashes 741.5±22.39ms 3.2±2.95s +331.56%

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