Skip to content

Commit 7e528aa

Browse files
authored
feat(common): add QA-facing upgrade report (#2157)
* feat(ci): add QA-facing upgrade report * fix(ci): install soldeer deps in upgrade report
1 parent 73e9e30 commit 7e528aa

File tree

5 files changed

+379
-114
lines changed

5 files changed

+379
-114
lines changed

.github/workflows/contracts-upgrade-version-check.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ jobs:
3636
- .github/workflows/contracts-upgrade-version-check.yml
3737
- ci/check-upgrade-versions.ts
3838
- ci/merge-address-constants.ts
39+
- ci/upgrade-version-check-lib.ts
3940
- host-contracts/**
4041
gateway-contracts:
4142
- .github/workflows/contracts-upgrade-version-check.yml
4243
- ci/check-upgrade-versions.ts
4344
- ci/merge-address-constants.ts
45+
- ci/upgrade-version-check-lib.ts
4446
- gateway-contracts/**
4547
4648
check:

ci/check-upgrade-versions.ts

Lines changed: 11 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -2,136 +2,33 @@
22
// Checks that upgradeable contracts have proper version bumps when bytecode changes.
33
// Usage: bun ci/check-upgrade-versions.ts <baseline-pkg-dir> <pr-pkg-dir>
44

5-
import { readFileSync, existsSync } from "fs";
6-
import { execSync } from "child_process";
7-
import { join } from "path";
5+
import { collectUpgradeVersionResults } from "./upgrade-version-check-lib";
86

97
const [baselineDir, prDir] = process.argv.slice(2);
108
if (!baselineDir || !prDir) {
119
console.error("Usage: bun ci/check-upgrade-versions.ts <baseline-pkg-dir> <pr-pkg-dir>");
1210
process.exit(1);
1311
}
1412

15-
const manifestPath = join(prDir, "upgrade-manifest.json");
16-
if (!existsSync(manifestPath)) {
17-
console.error(`::error::upgrade-manifest.json not found in ${prDir}`);
18-
process.exit(1);
19-
}
20-
21-
const VERSION_RE = /(?<name>REINITIALIZER_VERSION|MAJOR_VERSION|MINOR_VERSION|PATCH_VERSION)\s*=\s*(?<value>\d+)/g;
22-
23-
function extractVersions(filePath: string) {
24-
const source = readFileSync(filePath, "utf-8");
25-
const versions: Record<string, number> = {};
26-
for (const { groups } of source.matchAll(VERSION_RE)) {
27-
versions[groups!.name] = Number(groups!.value);
28-
}
29-
return { versions, source };
30-
}
31-
32-
function forgeInspect(contract: string, root: string): string | null {
33-
try {
34-
const raw = execSync(`forge inspect "contracts/${contract}.sol:${contract}" --root "${root}" deployedBytecode`, {
35-
encoding: "utf-8",
36-
stdio: ["pipe", "pipe", "pipe"],
37-
env: { ...process.env, NO_COLOR: "1" },
38-
});
39-
// Extract hex bytecode — forge may prepend ANSI codes or compilation progress to stdout
40-
const match = raw.match(/0x[0-9a-fA-F]+/);
41-
return match ? match[0] : null;
42-
} catch (e: any) {
43-
if (e.stderr) console.error(String(e.stderr));
44-
return null;
45-
}
46-
}
47-
48-
const contracts: string[] = JSON.parse(readFileSync(manifestPath, "utf-8"));
13+
const results = collectUpgradeVersionResults(baselineDir, prDir);
4914
let errors = 0;
5015

51-
for (const name of contracts) {
52-
console.log(`::group::Checking ${name}`);
16+
for (const result of results) {
17+
console.log(`::group::Checking ${result.name}`);
5318
try {
54-
const baseSol = join(baselineDir, "contracts", `${name}.sol`);
55-
const prSol = join(prDir, "contracts", `${name}.sol`);
56-
57-
if (!existsSync(baseSol)) {
58-
console.log(`Skipping ${name} (new contract, not in baseline)`);
59-
continue;
60-
}
61-
62-
if (!existsSync(prSol)) {
63-
console.error(`::error::${name} listed in upgrade-manifest.json but missing in PR`);
64-
errors++;
19+
if (!result.baselineExists) {
20+
console.log(`Skipping ${result.name} (new contract, not in baseline)`);
6521
continue;
6622
}
6723

68-
const { versions: baseV } = extractVersions(baseSol);
69-
const { versions: prV, source: prSrc } = extractVersions(prSol);
70-
71-
let parseFailed = false;
72-
for (const key of ["REINITIALIZER_VERSION", "MAJOR_VERSION", "MINOR_VERSION", "PATCH_VERSION"]) {
73-
if (baseV[key] == null || prV[key] == null) {
74-
console.error(`::error::Failed to parse ${key} for ${name}`);
75-
errors++;
76-
parseFailed = true;
77-
}
78-
}
79-
if (parseFailed) continue;
80-
81-
const prBytecode = forgeInspect(name, prDir);
82-
if (prBytecode == null) {
83-
console.error(`::error::Failed to compile ${name} on PR`);
84-
errors++;
85-
continue;
86-
}
87-
88-
const baseBytecode = forgeInspect(name, baselineDir);
89-
if (baseBytecode == null) {
90-
console.error(`::error::Failed to compile ${name} on baseline`);
91-
errors++;
92-
continue;
93-
}
94-
const bytecodeChanged = baseBytecode !== prBytecode;
95-
const reinitChanged = baseV.REINITIALIZER_VERSION !== prV.REINITIALIZER_VERSION;
96-
const versionChanged =
97-
baseV.MAJOR_VERSION !== prV.MAJOR_VERSION ||
98-
baseV.MINOR_VERSION !== prV.MINOR_VERSION ||
99-
baseV.PATCH_VERSION !== prV.PATCH_VERSION;
100-
101-
if (!bytecodeChanged) {
102-
console.log(`${name}: bytecode unchanged`);
103-
if (reinitChanged) {
104-
console.error(
105-
`::error::${name} REINITIALIZER_VERSION bumped (${baseV.REINITIALIZER_VERSION} -> ${prV.REINITIALIZER_VERSION}) but bytecode is unchanged`,
106-
);
107-
errors++;
108-
}
109-
continue;
110-
}
111-
112-
console.log(`${name}: bytecode CHANGED`);
113-
114-
if (!reinitChanged) {
115-
console.error(
116-
`::error::${name} bytecode changed but REINITIALIZER_VERSION was not bumped (still ${prV.REINITIALIZER_VERSION})`,
117-
);
118-
errors++;
24+
if (result.bytecodeChanged) {
25+
console.log(`${result.name}: bytecode CHANGED`);
11926
} else {
120-
// Convention: reinitializeV{N-1} for REINITIALIZER_VERSION=N
121-
const expectedFn = `reinitializeV${prV.REINITIALIZER_VERSION - 1}`;
122-
const uncommented = prSrc.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
123-
if (!new RegExp(`function\\s+${expectedFn}\\s*\\(`).test(uncommented)) {
124-
console.error(
125-
`::error::${name} has REINITIALIZER_VERSION=${prV.REINITIALIZER_VERSION} but no ${expectedFn}() function found`,
126-
);
127-
errors++;
128-
}
27+
console.log(`${result.name}: bytecode unchanged`);
12928
}
13029

131-
if (!versionChanged) {
132-
console.error(
133-
`::error::${name} bytecode changed but semantic version was not bumped (still v${prV.MAJOR_VERSION}.${prV.MINOR_VERSION}.${prV.PATCH_VERSION})`,
134-
);
30+
for (const error of result.errors) {
31+
console.error(`::error::${error}`);
13532
errors++;
13633
}
13734
} finally {

ci/list-upgrades.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env bun
2+
3+
import { execSync } from "child_process";
4+
import { mkdtempSync, rmSync } from "fs";
5+
import { tmpdir } from "os";
6+
import { dirname, join, resolve } from "path";
7+
8+
import { collectUpgradeVersionResults } from "./upgrade-version-check-lib";
9+
import { CONTRACT_HINTS, PACKAGE_CONSTRAINTS } from "./upgrade-report-hints";
10+
11+
type PackageName = "host-contracts" | "gateway-contracts";
12+
13+
const PACKAGE_CONFIG: Record<PackageName, { extraDeps?: string }> = {
14+
"host-contracts": { extraDeps: "forge soldeer install" },
15+
"gateway-contracts": {},
16+
};
17+
18+
function usage(): never {
19+
console.error("Usage: bun ci/list-upgrades.ts --from <tag/ref> [--to <tag/ref>] [--package host-contracts|gateway-contracts]");
20+
process.exit(1);
21+
}
22+
23+
function parseArgs() {
24+
const args = process.argv.slice(2);
25+
let fromRef: string | undefined;
26+
let toRef: string | undefined;
27+
const packages: PackageName[] = [];
28+
29+
for (let idx = 0; idx < args.length; idx++) {
30+
const arg = args[idx];
31+
if (arg === "--from") {
32+
fromRef = args[++idx];
33+
} else if (arg === "--to") {
34+
toRef = args[++idx];
35+
} else if (arg === "--package") {
36+
const value = args[++idx] as PackageName;
37+
if (value !== "host-contracts" && value !== "gateway-contracts") usage();
38+
packages.push(value);
39+
} else {
40+
usage();
41+
}
42+
}
43+
44+
if (!fromRef) usage();
45+
46+
return {
47+
fromRef,
48+
toRef,
49+
packages: packages.length > 0 ? packages : (["host-contracts", "gateway-contracts"] as PackageName[]),
50+
};
51+
}
52+
53+
function run(cmd: string, cwd: string) {
54+
execSync(cmd, { cwd, stdio: "inherit", env: { ...process.env, NO_COLOR: "1" } });
55+
}
56+
57+
function addWorktree(repoRoot: string, path: string, ref: string) {
58+
run(`git worktree add --detach "${path}" "${ref}"`, repoRoot);
59+
}
60+
61+
function preparePackage(currentRepoRoot: string, targetRoot: string, baselineRoot: string, pkg: PackageName) {
62+
const targetDir = join(targetRoot, pkg);
63+
const baselineDir = join(baselineRoot, pkg);
64+
const extraDeps = PACKAGE_CONFIG[pkg].extraDeps;
65+
66+
run("npm ci", targetDir);
67+
run("npm ci", baselineDir);
68+
if (extraDeps) {
69+
run(extraDeps, targetDir);
70+
run(extraDeps, baselineDir);
71+
}
72+
run("make ensure-addresses", targetDir);
73+
run("make ensure-addresses", baselineDir);
74+
run(`bun ci/merge-address-constants.ts "${join(baselineDir, "addresses")}" "${join(targetDir, "addresses")}"`, currentRepoRoot);
75+
run(`cp "${join(targetDir, "foundry.toml")}" "${join(baselineDir, "foundry.toml")}"`, currentRepoRoot);
76+
}
77+
78+
function printPackageReport(pkg: PackageName, repoRoot: string, baselineRoot: string) {
79+
const results = collectUpgradeVersionResults(join(baselineRoot, pkg), join(repoRoot, pkg));
80+
const changed = results.filter((result) => result.baselineExists && result.bytecodeChanged);
81+
const unchanged = results.filter((result) => result.baselineExists && !result.bytecodeChanged);
82+
const errors = results.flatMap((result) => result.errors.map((error) => `${result.name}: ${error}`));
83+
84+
console.log(`\n## ${pkg}`);
85+
86+
if (errors.length > 0) {
87+
console.log("\nErrors:");
88+
for (const error of errors) {
89+
console.log(`- ${error}`);
90+
}
91+
process.exitCode = 1;
92+
return;
93+
}
94+
95+
console.log("\nNeed upgrade:");
96+
for (const result of changed) {
97+
console.log(`- ${result.name}`);
98+
if (result.reinitializer) {
99+
console.log(` reinitializer: ${result.reinitializer.signature}`);
100+
console.log(` upgrade args: ${result.reinitializer.inputs.length > 0 ? "yes" : "no"}`);
101+
const defaults = CONTRACT_HINTS[pkg][result.name]?.defaults;
102+
if (defaults) {
103+
console.log(" task defaults:");
104+
for (const [name, value] of Object.entries(defaults)) {
105+
console.log(` - ${name} = ${value}`);
106+
}
107+
} else if (result.reinitializer.inputs.length > 0) {
108+
console.log(" note: check arg values with a repo owner");
109+
}
110+
}
111+
}
112+
113+
console.log("\nNo upgrade needed:");
114+
for (const result of unchanged) {
115+
console.log(`- ${result.name}`);
116+
}
117+
118+
const changedNames = new Set(changed.map((result) => result.name));
119+
const activeConstraints = PACKAGE_CONSTRAINTS[pkg].filter((constraint) =>
120+
constraint.contracts.every((contract) => changedNames.has(contract)),
121+
);
122+
if (activeConstraints.length > 0) {
123+
console.log("\nAttention points:");
124+
for (const constraint of activeConstraints) {
125+
console.log(`- ${constraint.message}`);
126+
}
127+
}
128+
}
129+
130+
const { fromRef, toRef, packages } = parseArgs();
131+
const repoRoot = resolve(dirname(import.meta.dir));
132+
const tempRoot = mkdtempSync(join(tmpdir(), "fhevm-upgrade-report-"));
133+
const baselineRoot = join(tempRoot, "baseline");
134+
const targetRoot = toRef ? join(tempRoot, "target") : repoRoot;
135+
136+
try {
137+
addWorktree(repoRoot, baselineRoot, fromRef);
138+
if (toRef) {
139+
addWorktree(repoRoot, targetRoot, toRef);
140+
}
141+
142+
for (const pkg of packages) {
143+
preparePackage(repoRoot, targetRoot, baselineRoot, pkg);
144+
printPackageReport(pkg, targetRoot, baselineRoot);
145+
}
146+
} finally {
147+
try {
148+
run("git worktree prune", repoRoot);
149+
} catch {}
150+
rmSync(tempRoot, { recursive: true, force: true });
151+
}

ci/upgrade-report-hints.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export interface UpgradeReportHint {
2+
defaults?: Record<string, string>;
3+
}
4+
5+
export interface UpgradeConstraint {
6+
contracts: string[];
7+
message: string;
8+
}
9+
10+
export const CONTRACT_HINTS: Record<string, Record<string, UpgradeReportHint>> = {
11+
"host-contracts": {
12+
HCULimit: {
13+
defaults: {
14+
hcuCapPerBlock: "281474976710655",
15+
maxHcuDepthPerTx: "5000000",
16+
maxHcuPerTx: "20000000",
17+
},
18+
},
19+
},
20+
"gateway-contracts": {},
21+
};
22+
23+
export const PACKAGE_CONSTRAINTS: Record<string, UpgradeConstraint[]> = {
24+
"host-contracts": [
25+
{
26+
contracts: ["HCULimit", "FHEVMExecutor"],
27+
message: "HCULimit and FHEVMExecutor both changed. Check whether they must be upgraded atomically or back-to-back.",
28+
},
29+
],
30+
"gateway-contracts": [],
31+
};

0 commit comments

Comments
 (0)