Skip to content

Commit 145ffbf

Browse files
committed
fix(ci): merge address constants from both baseline and PR
When comparing bytecode between the baseline tag and the PR, both sides must compile with identical address constants (they're embedded in bytecode). Previously we generated addresses only on the PR side and copied them to the baseline. This broke when contracts were removed between versions — the baseline still had source files importing the deleted constant, causing forge compilation to fail for the entire project, including unrelated unchanged contracts. Add ci/merge-address-constants.ts: generates addresses on both sides independently, then merges them. PR values win for shared constants, baseline-only constants (removed contracts) are preserved so the baseline compiles, and PR-only constants (new contracts) are preserved so the PR compiles. Reverts the source-comparison fallback in check-upgrade-hygiene.ts — both sides now compile successfully so we always compare real bytecode.
1 parent 8b65753 commit 145ffbf

File tree

3 files changed

+163
-19
lines changed

3 files changed

+163
-19
lines changed

.github/workflows/contracts-upgrade-hygiene.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ jobs:
3535
host-contracts:
3636
- .github/workflows/contracts-upgrade-hygiene.yml
3737
- ci/check-upgrade-hygiene.ts
38+
- ci/merge-address-constants.ts
3839
- host-contracts/**
3940
gateway-contracts:
4041
- .github/workflows/contracts-upgrade-hygiene.yml
4142
- ci/check-upgrade-hygiene.ts
43+
- ci/merge-address-constants.ts
4244
- gateway-contracts/**
4345
4446
check:
@@ -97,10 +99,16 @@ jobs:
9799
env:
98100
PACKAGE: ${{ matrix.package }}
99101
run: |
100-
# Generate addresses once and share — both sides must use identical values
101-
# because address constants are compiled into bytecode
102+
# Generate addresses on both sides independently, then merge them.
103+
# Address constants are embedded in bytecode, so both sides must compile
104+
# with identical values. We can't just copy one side's addresses to the
105+
# other because contracts may be added or removed between versions — the
106+
# baseline would fail to compile if it references a removed constant, or
107+
# the PR would fail if it references a new one. Merging gives both sides
108+
# the full union of constants with consistent values (PR wins for shared).
102109
(cd "$PACKAGE" && make ensure-addresses)
103-
cp -r "$PACKAGE/addresses" "baseline/$PACKAGE/addresses"
110+
(cd "baseline/$PACKAGE" && make ensure-addresses)
111+
bun ci/merge-address-constants.ts "baseline/$PACKAGE/addresses" "$PACKAGE/addresses"
104112
# Use PR's foundry.toml for both so compiler settings match (cbor_metadata, bytecode_hash)
105113
cp "$PACKAGE/foundry.toml" "baseline/$PACKAGE/foundry.toml"
106114

ci/check-upgrade-hygiene.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -85,24 +85,13 @@ for (const name of contracts) {
8585
continue;
8686
}
8787

88-
// Baseline may fail to compile if unrelated contracts were restructured (e.g. deleted
89-
// imports cause forge to fail on the whole project). When that happens, fall back to
90-
// comparing source files: if this contract's source is identical, bytecode is unchanged.
9188
const baseBytecode = forgeInspect(name, baselineDir);
92-
let bytecodeChanged: boolean;
93-
if (baseBytecode != null) {
94-
bytecodeChanged = baseBytecode !== prBytecode;
95-
} else {
96-
const baseSrc = readFileSync(baseSol, "utf-8");
97-
const prSrcRaw = readFileSync(prSol, "utf-8");
98-
if (baseSrc === prSrcRaw) {
99-
console.log(`${name}: baseline compilation failed but source is identical, treating as unchanged`);
100-
bytecodeChanged = false;
101-
} else {
102-
console.log(`${name}: baseline compilation failed and source differs, treating as changed`);
103-
bytecodeChanged = true;
104-
}
89+
if (baseBytecode == null) {
90+
console.error(`::error::Failed to compile ${name} on baseline`);
91+
errors++;
92+
continue;
10593
}
94+
const bytecodeChanged = baseBytecode !== prBytecode;
10695
const reinitChanged = baseV.REINITIALIZER_VERSION !== prV.REINITIALIZER_VERSION;
10796
const versionChanged =
10897
baseV.MAJOR_VERSION !== prV.MAJOR_VERSION ||

ci/merge-address-constants.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env bun
2+
//
3+
// Merges Solidity address-constant files from a baseline and a PR so that both
4+
// sides can compile against the same unified set of constants.
5+
//
6+
// WHY THIS IS NEEDED
7+
// ──────────────────
8+
// We compare compiled bytecode between a baseline tag (last deployed release)
9+
// and the PR to detect contract changes. Both sides must compile with
10+
// *identical* address constants because those constants are embedded in
11+
// bytecode — any difference would cause a false "bytecode changed" signal.
12+
//
13+
// The naive approach (generate addresses on the PR side, copy to baseline)
14+
// breaks when contracts are added or removed between versions. For example,
15+
// if the PR deletes MultichainACL, the generated addresses file no longer
16+
// contains `multichainACLAddress`. But the baseline still has source files
17+
// that import it, so forge compilation fails for the *entire* project —
18+
// including unrelated contracts like GatewayConfig that haven't changed.
19+
//
20+
// The fix: generate addresses on BOTH sides, then merge. PR values win for
21+
// shared constants (so both sides embed the same values). Constants that only
22+
// exist in the baseline (removed contracts) are preserved so the baseline
23+
// compiles. Constants that only exist in the PR (new contracts) are preserved
24+
// so the PR compiles. The merged file is copied to both sides.
25+
//
26+
// USAGE
27+
// bun ci/merge-address-constants.ts <baseline-addresses-dir> <pr-addresses-dir>
28+
//
29+
// For each .sol file present in either directory, writes a merged version to
30+
// BOTH directories. Exits 0 on success, 1 on error.
31+
32+
import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
33+
import { join } from "path";
34+
35+
const ADDRESS_RE = /^address\s+constant\s+(\w+)\s*=\s*(0x[0-9a-fA-F]+)\s*;/;
36+
37+
interface AddressConstant {
38+
name: string;
39+
value: string;
40+
line: string; // original line for faithful reproduction
41+
}
42+
43+
/**
44+
* Parse a Solidity address-constants file into its header (SPDX + pragma) and
45+
* an ordered list of address constants.
46+
*/
47+
function parseAddressFile(content: string): { header: string; constants: AddressConstant[] } {
48+
const lines = content.split("\n");
49+
const constants: AddressConstant[] = [];
50+
const headerLines: string[] = [];
51+
let inHeader = true;
52+
53+
for (const line of lines) {
54+
const match = line.match(ADDRESS_RE);
55+
if (match) {
56+
inHeader = false;
57+
constants.push({ name: match[1], value: match[2], line });
58+
} else if (inHeader) {
59+
headerLines.push(line);
60+
}
61+
// Skip blank lines between constants — we regenerate spacing
62+
}
63+
64+
return { header: headerLines.join("\n"), constants };
65+
}
66+
67+
/**
68+
* Merge two parsed address files. PR constants take precedence for shared
69+
* names. Baseline-only constants are appended at the end.
70+
*/
71+
function mergeConstants(
72+
baseline: AddressConstant[],
73+
pr: AddressConstant[],
74+
): AddressConstant[] {
75+
const seen = new Set<string>();
76+
const merged: AddressConstant[] = [];
77+
78+
// PR constants first, in PR order — these values win for shared names
79+
for (const c of pr) {
80+
merged.push(c);
81+
seen.add(c.name);
82+
}
83+
84+
// Baseline-only constants (removed in PR) — appended so baseline compiles
85+
for (const c of baseline) {
86+
if (!seen.has(c.name)) {
87+
merged.push(c);
88+
}
89+
}
90+
91+
return merged;
92+
}
93+
94+
/**
95+
* Render merged constants back to a Solidity file.
96+
*/
97+
function renderAddressFile(header: string, constants: AddressConstant[]): string {
98+
const lines = constants.map((c) => c.line);
99+
return header.trimEnd() + "\n\n" + lines.join("\n") + "\n";
100+
}
101+
102+
// --- Main ---
103+
104+
const [baselineDir, prDir] = process.argv.slice(2);
105+
if (!baselineDir || !prDir) {
106+
console.error("Usage: bun ci/merge-address-constants.ts <baseline-addresses-dir> <pr-addresses-dir>");
107+
process.exit(1);
108+
}
109+
110+
// Collect all .sol filenames from both directories
111+
const baselineFiles = existsSync(baselineDir)
112+
? readdirSync(baselineDir).filter((f) => f.endsWith(".sol"))
113+
: [];
114+
const prFiles = existsSync(prDir)
115+
? readdirSync(prDir).filter((f) => f.endsWith(".sol"))
116+
: [];
117+
const allFiles = [...new Set([...baselineFiles, ...prFiles])];
118+
119+
for (const file of allFiles) {
120+
const baselinePath = join(baselineDir, file);
121+
const prPath = join(prDir, file);
122+
123+
const hasBaseline = existsSync(baselinePath);
124+
const hasPR = existsSync(prPath);
125+
126+
if (hasBaseline && hasPR) {
127+
// Merge: PR values win for shared constants, baseline-only constants preserved
128+
const baselineParsed = parseAddressFile(readFileSync(baselinePath, "utf-8"));
129+
const prParsed = parseAddressFile(readFileSync(prPath, "utf-8"));
130+
const merged = mergeConstants(baselineParsed.constants, prParsed.constants);
131+
const output = renderAddressFile(prParsed.header, merged);
132+
133+
console.log(`${file}: merged (${prParsed.constants.length} PR + ${baselineParsed.constants.length} baseline → ${merged.length} total)`);
134+
writeFileSync(baselinePath, output);
135+
writeFileSync(prPath, output);
136+
} else if (hasBaseline) {
137+
// File only in baseline (removed in PR) — copy to PR so baseline imports resolve
138+
console.log(`${file}: baseline-only, copying to PR`);
139+
writeFileSync(prPath, readFileSync(baselinePath, "utf-8"));
140+
} else {
141+
// File only in PR (new) — copy to baseline so PR imports resolve
142+
console.log(`${file}: PR-only, copying to baseline`);
143+
writeFileSync(baselinePath, readFileSync(prPath, "utf-8"));
144+
}
145+
}
146+
147+
console.log("Address constants merged successfully");

0 commit comments

Comments
 (0)