diff --git a/.github/workflows/contracts-abi-compat-check.yml b/.github/workflows/contracts-abi-compat-check.yml new file mode 100644 index 0000000000..fccd62b942 --- /dev/null +++ b/.github/workflows/contracts-abi-compat-check.yml @@ -0,0 +1,109 @@ +name: contracts-abi-compat-check + +# Default to no token permissions at the workflow level; each job opts into the +# minimum read scopes it needs. +permissions: {} + +on: + pull_request: + +env: + ABI_COMPAT_FROM_TAG: v0.11.1 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + check-changes: + name: contracts-abi-compat-check/check-changes + permissions: + contents: read # Required by actions/checkout to clone the repository + pull-requests: read # Required by dorny/paths-filter to inspect changed files + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.filter.outputs.changes }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: "false" + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 + id: filter + with: + filters: | + host-contracts: + - .github/workflows/contracts-abi-compat-check.yml + - ci/abi-compat/** + - ci/shared/** + - host-contracts/** + gateway-contracts: + - .github/workflows/contracts-abi-compat-check.yml + - ci/abi-compat/** + - ci/shared/** + - gateway-contracts/** + + check: + name: contracts-abi-compat-check/${{ matrix.package }} + needs: check-changes + if: ${{ needs.check-changes.outputs.packages != '[]' }} + permissions: + contents: read # Required by actions/checkout to clone the PR branch and baseline tag + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.check-changes.outputs.packages) }} + include: + - package: host-contracts + extra-deps: forge soldeer install + - package: gateway-contracts + extra-deps: "" + steps: + - name: Checkout PR branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: "false" + + - name: Checkout baseline + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ env.ABI_COMPAT_FROM_TAG }} + path: baseline + persist-credentials: "false" + + - name: Install Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@82dee4ba654bd2146511f85f0d013af94670c4de + + - name: Install PR dependencies + working-directory: ${{ matrix.package }} + run: npm ci + + - name: Install baseline dependencies + working-directory: baseline/${{ matrix.package }} + run: npm ci + + - name: Install Forge dependencies + if: matrix.extra-deps != '' + env: + PACKAGE: ${{ matrix.package }} + EXTRA_DEPS: ${{ matrix.extra-deps }} + run: | + (cd "$PACKAGE" && $EXTRA_DEPS) + (cd "baseline/$PACKAGE" && $EXTRA_DEPS) + + - name: Setup compilation + env: + PACKAGE: ${{ matrix.package }} + run: | + (cd "$PACKAGE" && make ensure-addresses) + (cd "baseline/$PACKAGE" && make ensure-addresses) + bun ci/shared/merge-address-constants.ts "baseline/$PACKAGE/addresses" "$PACKAGE/addresses" + cp "$PACKAGE/foundry.toml" "baseline/$PACKAGE/foundry.toml" + + - name: Run ABI compatibility check + env: + PACKAGE: ${{ matrix.package }} + run: bun ci/abi-compat/check.ts "baseline/$PACKAGE" "$PACKAGE" "$PACKAGE" diff --git a/.github/workflows/contracts-upgrade-version-check.yml b/.github/workflows/contracts-upgrade-version-check.yml index c9a70e17bb..5946f17fe5 100644 --- a/.github/workflows/contracts-upgrade-version-check.yml +++ b/.github/workflows/contracts-upgrade-version-check.yml @@ -34,15 +34,13 @@ jobs: filters: | host-contracts: - .github/workflows/contracts-upgrade-version-check.yml - - ci/check-upgrade-versions.ts - - ci/merge-address-constants.ts - - ci/upgrade-version-check-lib.ts + - ci/upgrade-check/** + - ci/shared/** - host-contracts/** gateway-contracts: - .github/workflows/contracts-upgrade-version-check.yml - - ci/check-upgrade-versions.ts - - ci/merge-address-constants.ts - - ci/upgrade-version-check-lib.ts + - ci/upgrade-check/** + - ci/shared/** - gateway-contracts/** check: @@ -110,11 +108,11 @@ jobs: # the full union of constants with consistent values (PR wins for shared). (cd "$PACKAGE" && make ensure-addresses) (cd "baseline/$PACKAGE" && make ensure-addresses) - bun ci/merge-address-constants.ts "baseline/$PACKAGE/addresses" "$PACKAGE/addresses" + bun ci/shared/merge-address-constants.ts "baseline/$PACKAGE/addresses" "$PACKAGE/addresses" # Use PR's foundry.toml for both so compiler settings match (cbor_metadata, bytecode_hash) cp "$PACKAGE/foundry.toml" "baseline/$PACKAGE/foundry.toml" - name: Run upgrade version check env: PACKAGE: ${{ matrix.package }} - run: bun ci/check-upgrade-versions.ts "baseline/$PACKAGE" "$PACKAGE" + run: bun ci/upgrade-check/check.ts "baseline/$PACKAGE" "$PACKAGE" diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000000..fb2603f4d2 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,5 @@ +# CI Scripts + +- [`abi-compat/`](./abi-compat/README.md): contract ABI compatibility checks +- [`upgrade-check/`](./upgrade-check/): contract upgrade version checks +- [`shared/`](./shared/): utilities shared across CI subsystems diff --git a/ci/abi-compat/README.md b/ci/abi-compat/README.md new file mode 100644 index 0000000000..9dfdb95a99 --- /dev/null +++ b/ci/abi-compat/README.md @@ -0,0 +1,24 @@ +# ABI Compatibility + +ABI coverage is derived from each package's `upgrade-manifest.json`. +Stable-surface policy lives in [`config.ts`](./config.ts): initializer, ownership, upgrade, +and other intentionally non-public entrypoints are excluded there. + +Compare the stable contract ABI surface between two refs locally with: + +```bash +bun ci/abi-compat/list.ts --from v0.11.1 --to v0.12.0-0 +``` + +Limit the scope with: + +```bash +bun ci/abi-compat/list.ts --from v0.11.1 --to v0.12.0-0 --package host-contracts +bun ci/abi-compat/list.ts --from v0.11.1 --to v0.12.0-0 --package gateway-contracts +``` + +Use the lower-level checker when both package directories are already prepared: + +```bash +bun ci/abi-compat/check.ts +``` diff --git a/ci/abi-compat/check.ts b/ci/abi-compat/check.ts new file mode 100644 index 0000000000..a71a84d696 --- /dev/null +++ b/ci/abi-compat/check.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env bun +import { PACKAGE_CONFIG, type PackageName } from "./config"; +import { collectAbiCompatResults } from "./lib"; + +const [baselineDir, targetDir, pkg] = process.argv.slice(2) as [ + string | undefined, + string | undefined, + PackageName | undefined, +]; + +if (!baselineDir || !targetDir || !pkg || !(pkg in PACKAGE_CONFIG)) { + console.error( + "Usage: bun ci/abi-compat/check.ts ", + ); + process.exit(1); +} + +const results = collectAbiCompatResults(baselineDir, targetDir, pkg); +let errors = 0; + +for (const result of results) { + console.log(`::group::Checking ${result.name}`); + try { + if (!result.baselineExists) { + console.log(`Skipping ${result.name} (new contract, not in baseline)`); + continue; + } + + console.log( + `${result.name}: ${result.baselineStableCount} stable baseline ABI entries, ${result.targetStableCount} stable target ABI entries`, + ); + + for (const error of result.errors) { + console.error(`::error::${error}`); + errors++; + } + + for (const signature of result.missing) { + console.error(`::error::${result.name} missing stable ABI signature: ${signature}`); + errors++; + } + + if (result.allowedMissing.length > 0) { + console.log(`${result.name}: accepted stable ABI exceptions`); + for (const signature of result.allowedMissing) { + console.log(` ~ ${signature}`); + } + } + + if (result.added.length > 0) { + console.log(`${result.name}: added stable ABI entries`); + for (const signature of result.added) { + console.log(` + ${signature}`); + } + } + + if (result.errors.length === 0 && result.missing.length === 0 && result.added.length === 0) { + console.log(`${result.name}: no stable ABI changes`); + } + } finally { + console.log("::endgroup::"); + } +} + +if (errors > 0) { + console.error(`::error::ABI compatibility check failed with ${errors} error(s)`); + process.exit(1); +} + +console.log("All contracts passed ABI compatibility checks"); diff --git a/ci/abi-compat/config.ts b/ci/abi-compat/config.ts new file mode 100644 index 0000000000..97a0c9b94e --- /dev/null +++ b/ci/abi-compat/config.ts @@ -0,0 +1,42 @@ +export type PackageName = "host-contracts" | "gateway-contracts"; + +export const EXCLUDED_MODIFIERS = new Set([ + "onlyOwner", + "onlyACLOwner", + "onlyGatewayOwner", + "onlyFromEmptyProxy", + "onlyPauser", + "onlyCoprocessorTxSender", + "onlyKmsTxSender", + "onlyRegisteredHostChain", + "onlyHandleFromRegisteredHostChain", + "onlyDecryptionContract", + "onlyInputVerificationContract", +]); + +export const EXCLUDED_FUNCTION_PATTERNS = [ + /^initialize/, + /^reinitializeV\d+$/, + /^acceptOwnership$/, + /^owner$/, + /^transferOwnership$/, + /^upgradeToAndCall$/, +]; + +// ABI coverage is derived from each package's upgrade-manifest.json. +// Keep only stable-surface exclusions here. +export const EXCLUDED_CONTRACT_FUNCTION_PATTERNS: Record = { + HCULimit: [/^checkHCUFor/], +}; + +export const PACKAGE_CONFIG: Record< + PackageName, + { + extraDeps?: string; + } +> = { + "host-contracts": { + extraDeps: "forge soldeer install", + }, + "gateway-contracts": {}, +}; diff --git a/ci/abi-compat/exceptions.ts b/ci/abi-compat/exceptions.ts new file mode 100644 index 0000000000..fdbe64de32 --- /dev/null +++ b/ci/abi-compat/exceptions.ts @@ -0,0 +1,22 @@ +import type { PackageName } from "./config"; + +export const ABI_COMPAT_EXCEPTIONS: Partial>>> = { + "host-contracts": { + ACL: ["error ExpirationDateBeforeOneHour()"], + KMSVerifier: ["event NewContextSet(address[],uint256)"], + }, + "gateway-contracts": { + Decryption: [ + "error AccountNotAllowedToUseCiphertext(bytes32,address)", + "error PublicDecryptNotAllowed(bytes32)", + "error UserDecryptionNotDelegated(uint256,address,address,address)", + "function isDelegatedUserDecryptionReady((address,address),(bytes32,address)[],bytes) returns (bool)", + ], + GatewayConfig: [ + "event InitializeGatewayConfig((string,string),(uint256,uint256,uint256,uint256,uint256),(address,address,string,string)[],(address,address,string)[],(address,address,bytes)[])", + "event UpdateKmsNodes((address,address,string,string)[],uint256,uint256,uint256,uint256)", + "function getPublicDecryptionThreshold() returns (uint256)", + "function getUserDecryptionThreshold() returns (uint256)", + ], + }, +}; diff --git a/ci/abi-compat/lib.ts b/ci/abi-compat/lib.ts new file mode 100644 index 0000000000..38bb23e1e8 --- /dev/null +++ b/ci/abi-compat/lib.ts @@ -0,0 +1,262 @@ +import { execSync } from "child_process"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + +import { + EXCLUDED_CONTRACT_FUNCTION_PATTERNS, + EXCLUDED_FUNCTION_PATTERNS, + EXCLUDED_MODIFIERS, + PACKAGE_CONFIG, + type PackageName, +} from "./config"; +import { ABI_COMPAT_EXCEPTIONS } from "./exceptions"; + +type AbiParam = { + type: string; + indexed?: boolean; + components?: AbiParam[]; +}; + +type AbiEntry = { + type: string; + name?: string; + inputs?: AbiParam[]; + outputs?: AbiParam[]; + anonymous?: boolean; +}; + +export type AbiCompatResult = { + name: string; + baselineExists: boolean; + baselineStableCount: number; + targetStableCount: number; + missing: string[]; + allowedMissing: string[]; + added: string[]; + errors: string[]; +}; + +function runJson(command: string) { + return execSync(command, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1" }, + }); +} + +function formatRawOutput(raw: string) { + const output = raw.trim(); + if (!output) { + return ""; + } + + const maxLen = 2000; + return output.length > maxLen ? output.slice(-maxLen) : output; +} + +function forgeInspectAbi(contract: string, root: string): AbiEntry[] | null { + try { + const raw = runJson(`forge inspect "contracts/${contract}.sol:${contract}" abi --root "${root}" --json --force`); + // Forge may prepend compilation progress to stdout on the first invocation in a + // clean directory. Extract the JSON array instead of parsing the whole output. + const jsonStart = raw.indexOf("["); + if (jsonStart === -1) { + console.error(`forge inspect for ${contract}: no JSON array found in output\n${formatRawOutput(raw)}`); + return null; + } + + try { + return JSON.parse(raw.slice(jsonStart)); + } catch (error) { + console.error( + `forge inspect for ${contract}: failed to parse JSON output\n${ + error instanceof Error ? error.message : String(error) + }\n${formatRawOutput(raw)}`, + ); + return null; + } + } catch (error: any) { + if (error.stderr) { + console.error(String(error.stderr)); + } + return null; + } +} + +function canonicalType(param: AbiParam): string { + const suffix = param.type.match(/(\[[^\]]*\])+$/)?.[0] ?? ""; + if (!param.type.startsWith("tuple")) { + return param.type; + } + const components = (param.components ?? []).map(canonicalType).join(","); + return `(${components})${suffix}`; +} + +function canonicalSignature(entry: AbiEntry): string | null { + if (!entry.name) { + return null; + } + + const inputs = (entry.inputs ?? []).map((input) => { + const indexed = entry.type === "event" && input.indexed ? " indexed" : ""; + return `${canonicalType(input)}${indexed}`; + }); + + if (entry.type === "function") { + const outputs = (entry.outputs ?? []).map(canonicalType).join(","); + return `function ${entry.name}(${inputs.join(",")}) returns (${outputs})`; + } + + if (entry.type === "event") { + const anonymous = entry.anonymous ? " anonymous" : ""; + return `event ${entry.name}(${inputs.join(",")})${anonymous}`; + } + + if (entry.type === "error") { + return `error ${entry.name}(${inputs.join(",")})`; + } + + return null; +} + +function stripComments(source: string) { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); +} + +function excludedFunctionNames(contract: string, source: string) { + const names = new Set(); + const sanitized = stripComments(source); + const matcher = /function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*([^{;]*)[;{]/g; + const contractPatterns = EXCLUDED_CONTRACT_FUNCTION_PATTERNS[contract] ?? []; + + for (const match of sanitized.matchAll(matcher)) { + const name = match[1]; + if ( + EXCLUDED_FUNCTION_PATTERNS.some((pattern) => pattern.test(name)) || + contractPatterns.some((pattern) => pattern.test(name)) + ) { + names.add(name); + continue; + } + + const declarationSuffix = match[3].split(/\breturns\b/)[0] ?? ""; + const tokens = declarationSuffix.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?? []; + if (tokens.some((token) => EXCLUDED_MODIFIERS.has(token))) { + names.add(name); + } + } + + return names; +} + +function collectStableSignatures(contract: string, root: string, abi: AbiEntry[]) { + const sourcePath = join(root, "contracts", `${contract}.sol`); + if (!existsSync(sourcePath)) { + return { signatures: new Set(), count: 0, error: `Missing source file for ${contract}: ${sourcePath}` }; + } + + const excludedNames = excludedFunctionNames(contract, readFileSync(sourcePath, "utf-8")); + const contractPatterns = EXCLUDED_CONTRACT_FUNCTION_PATTERNS[contract] ?? []; + const signatures = new Set(); + + for (const entry of abi) { + if (!["function", "event", "error"].includes(entry.type)) { + continue; + } + if ( + entry.name && + (EXCLUDED_FUNCTION_PATTERNS.some((pattern) => pattern.test(entry.name)) || + contractPatterns.some((pattern) => pattern.test(entry.name))) + ) { + continue; + } + if (entry.type === "function" && entry.name && excludedNames.has(entry.name)) { + continue; + } + const signature = canonicalSignature(entry); + if (signature) { + signatures.add(signature); + } + } + + return { signatures, count: signatures.size }; +} + +function readManifestContracts(root: string) { + const manifestPath = join(root, "upgrade-manifest.json"); + if (!existsSync(manifestPath)) { + throw new Error(`upgrade-manifest.json not found in ${root}`); + } + return JSON.parse(readFileSync(manifestPath, "utf-8")) as string[]; +} + +export function collectAbiCompatResults(baselineDir: string, targetDir: string, pkg: PackageName): AbiCompatResult[] { + const contracts = readManifestContracts(targetDir); + + return contracts.map((name) => { + const baselineSource = join(baselineDir, "contracts", `${name}.sol`); + if (!existsSync(baselineSource)) { + return { + name, + baselineExists: false, + baselineStableCount: 0, + targetStableCount: 0, + missing: [], + allowedMissing: [], + added: [], + errors: [], + }; + } + + const baselineAbi = forgeInspectAbi(name, baselineDir); + const targetAbi = forgeInspectAbi(name, targetDir); + const errors: string[] = []; + if (baselineAbi == null) { + errors.push(`Failed to inspect baseline ABI for ${name}`); + } + if (targetAbi == null) { + errors.push(`Failed to inspect target ABI for ${name}`); + } + if (errors.length > 0 || baselineAbi == null || targetAbi == null) { + return { + name, + baselineExists: true, + baselineStableCount: 0, + targetStableCount: 0, + missing: [], + allowedMissing: [], + added: [], + errors, + }; + } + + const baselineStable = collectStableSignatures(name, baselineDir, baselineAbi); + const targetStable = collectStableSignatures(name, targetDir, targetAbi); + if (baselineStable.error) { + errors.push(baselineStable.error); + } + if (targetStable.error) { + errors.push(targetStable.error); + } + + const allowedMissingSet = new Set(ABI_COMPAT_EXCEPTIONS[pkg]?.[name] ?? []); + const missing = [...baselineStable.signatures] + .filter((signature) => !targetStable.signatures.has(signature) && !allowedMissingSet.has(signature)) + .sort(); + const allowedMissing = [...baselineStable.signatures] + .filter((signature) => !targetStable.signatures.has(signature) && allowedMissingSet.has(signature)) + .sort(); + const added = [...targetStable.signatures].filter((signature) => !baselineStable.signatures.has(signature)).sort(); + + return { + name, + baselineExists: true, + baselineStableCount: baselineStable.count, + targetStableCount: targetStable.count, + missing, + allowedMissing, + added, + errors, + }; + }); +} diff --git a/ci/abi-compat/list.ts b/ci/abi-compat/list.ts new file mode 100644 index 0000000000..4629e960a6 --- /dev/null +++ b/ci/abi-compat/list.ts @@ -0,0 +1,220 @@ +#!/usr/bin/env bun +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; + +import { PACKAGE_CONFIG, type PackageName } from "./config"; +import { collectAbiCompatResults } from "./lib"; + +function usage(): never { + console.error( + "Usage: bun ci/abi-compat/list.ts --from [--to ] [--package host-contracts|gateway-contracts]", + ); + process.exit(1); +} + +function logStep(message: string) { + console.log(`\n==> ${message}`); +} + +function formatExecError(error: unknown) { + if (!(error instanceof Error)) { + return String(error); + } + + const execError = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer }; + const stdout = execError.stdout ? String(execError.stdout).trim() : ""; + const stderr = execError.stderr ? String(execError.stderr).trim() : ""; + const details = [stderr, stdout].filter(Boolean).join("\n"); + if (!details) { + return execError.message; + } + + const maxLen = 2000; + return details.length > maxLen ? `${details.slice(-maxLen)}` : details; +} + +function parseArgs() { + const args = process.argv.slice(2); + let fromRef: string | undefined; + let toRef: string | undefined; + const packages: PackageName[] = []; + + for (let idx = 0; idx < args.length; idx++) { + const arg = args[idx]; + if (arg === "--from") { + fromRef = args[++idx]; + } else if (arg === "--to") { + toRef = args[++idx]; + } else if (arg === "--package") { + const value = args[++idx] as PackageName; + if (!(value in PACKAGE_CONFIG)) { + usage(); + } + packages.push(value); + } else { + usage(); + } + } + + if (!fromRef) { + usage(); + } + + return { + fromRef, + toRef, + packages: packages.length > 0 ? packages : (Object.keys(PACKAGE_CONFIG) as PackageName[]), + }; +} + +function run(cmd: string, cwd: string) { + try { + execSync(cmd, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: { ...process.env, NO_COLOR: "1" }, + }); + } catch (error) { + throw new Error(`Command failed in ${cwd}: ${cmd}\n${formatExecError(error)}`); + } +} + +function addWorktree(repoRoot: string, path: string, ref: string) { + run(`git worktree add --detach "${path}" "${ref}"`, repoRoot); +} + +function preparePackage(currentRepoRoot: string, targetRoot: string, baselineRoot: string, pkg: PackageName) { + const baselineDir = join(baselineRoot, pkg); + const targetDir = join(targetRoot, pkg); + const extraDeps = PACKAGE_CONFIG[pkg].extraDeps; + + logStep(`Installing dependencies for ${pkg}`); + run("npm ci", baselineDir); + run("npm ci", targetDir); + if (extraDeps) { + logStep(`Installing extra build dependencies for ${pkg}`); + run(extraDeps, baselineDir); + run(extraDeps, targetDir); + } + + logStep(`Generating local compile-time addresses for ${pkg} (no real network deployment)`); + run("make ensure-addresses", baselineDir); + run("make ensure-addresses", targetDir); + logStep(`Normalizing address constants for ${pkg}`); + run( + `bun ci/shared/merge-address-constants.ts "${join(baselineDir, "addresses")}" "${join(targetDir, "addresses")}"`, + currentRepoRoot, + ); + run(`cp "${join(targetDir, "foundry.toml")}" "${join(baselineDir, "foundry.toml")}"`, currentRepoRoot); +} + +function printPackageReport(baselineRoot: string, targetRoot: string, pkg: PackageName) { + const results = collectAbiCompatResults(join(baselineRoot, pkg), join(targetRoot, pkg), pkg); + + logStep(`Comparing stable ABI surface for ${pkg}`); + let packageFailures = 0; + + for (const result of results) { + if (!result.baselineExists) { + continue; + } + + console.log(`\n### ${result.name}`); + console.log(`baseline stable entries: ${result.baselineStableCount}`); + console.log(`target stable entries: ${result.targetStableCount}`); + + if (result.errors.length > 0) { + for (const error of result.errors) { + console.log(`error: ${error}`); + packageFailures++; + } + continue; + } + + if (result.missing.length === 0) { + console.log("missing stable signatures: none"); + } else { + console.log("missing stable signatures:"); + for (const signature of result.missing) { + console.log(`- ${signature}`); + } + packageFailures += result.missing.length; + } + + if (result.added.length === 0) { + console.log("added stable signatures: none"); + } else { + console.log("added stable signatures:"); + for (const signature of result.added) { + console.log(`+ ${signature}`); + } + } + } + + return packageFailures; +} + +const { fromRef, toRef, packages } = parseArgs(); +const repoRoot = resolve(import.meta.dir, "../.."); +const tempRoot = mkdtempSync(join(tmpdir(), "fhevm-abi-compat-")); +const baselineRoot = join(tempRoot, "baseline"); +const targetRoot = toRef ? join(tempRoot, "target") : repoRoot; + +let totalFailures = 0; +let runFailed = false; + +try { + logStep(`Using temporary workspace at ${tempRoot}`); + logStep(`Preparing temporary baseline checkout for ${fromRef}`); + addWorktree(repoRoot, baselineRoot, fromRef); + if (toRef) { + logStep(`Preparing temporary target checkout for ${toRef}`); + addWorktree(repoRoot, targetRoot, toRef); + } else { + logStep("Using current checkout as target"); + } + + for (const pkg of packages) { + preparePackage(repoRoot, targetRoot, baselineRoot, pkg); + totalFailures += printPackageReport(baselineRoot, targetRoot, pkg); + } +} catch (error) { + runFailed = true; + const message = error instanceof Error ? error.message : String(error); + console.error(`\nABI compatibility run failed: ${message}`); +} finally { + if (toRef && existsSync(targetRoot)) { + logStep(`Cleaning temporary target checkout ${targetRoot}`); + try { + run(`git worktree remove --force "${targetRoot}"`, repoRoot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to clean target worktree: ${message}`); + } + } + if (existsSync(baselineRoot)) { + logStep(`Cleaning temporary baseline checkout ${baselineRoot}`); + try { + run(`git worktree remove --force "${baselineRoot}"`, repoRoot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to clean baseline worktree: ${message}`); + } + } + rmSync(tempRoot, { recursive: true, force: true }); + logStep(`Removed temporary workspace ${tempRoot}`); +} + +if (runFailed) { + process.exit(1); +} + +if (totalFailures > 0) { + console.error(`\nABI compatibility check found ${totalFailures} issue(s)`); + process.exit(1); +} + +console.log("\nABI compatibility check passed"); diff --git a/ci/merge-address-constants.ts b/ci/shared/merge-address-constants.ts similarity index 96% rename from ci/merge-address-constants.ts rename to ci/shared/merge-address-constants.ts index 68c0518a8b..30b4493e43 100644 --- a/ci/merge-address-constants.ts +++ b/ci/shared/merge-address-constants.ts @@ -24,7 +24,7 @@ // so the PR compiles. The merged file is copied to both sides. // // USAGE -// bun ci/merge-address-constants.ts +// bun ci/shared/merge-address-constants.ts // // For each .sol file present in either directory, writes a merged version to // BOTH directories. Exits 0 on success, 1 on error. @@ -103,7 +103,7 @@ function renderAddressFile(header: string, constants: AddressConstant[]): string const [baselineDir, prDir] = process.argv.slice(2); if (!baselineDir || !prDir) { - console.error("Usage: bun ci/merge-address-constants.ts "); + console.error("Usage: bun ci/shared/merge-address-constants.ts "); process.exit(1); } diff --git a/ci/check-upgrade-versions.ts b/ci/upgrade-check/check.ts similarity index 80% rename from ci/check-upgrade-versions.ts rename to ci/upgrade-check/check.ts index 8f50d91dd0..c0a492142d 100644 --- a/ci/check-upgrade-versions.ts +++ b/ci/upgrade-check/check.ts @@ -1,12 +1,12 @@ #!/usr/bin/env bun // Checks that upgradeable contracts have proper version bumps when bytecode changes. -// Usage: bun ci/check-upgrade-versions.ts +// Usage: bun ci/upgrade-check/check.ts -import { collectUpgradeVersionResults } from "./upgrade-version-check-lib"; +import { collectUpgradeVersionResults } from "./lib"; const [baselineDir, prDir] = process.argv.slice(2); if (!baselineDir || !prDir) { - console.error("Usage: bun ci/check-upgrade-versions.ts "); + console.error("Usage: bun ci/upgrade-check/check.ts "); process.exit(1); } diff --git a/ci/upgrade-check/config.ts b/ci/upgrade-check/config.ts new file mode 100644 index 0000000000..be52715a59 --- /dev/null +++ b/ci/upgrade-check/config.ts @@ -0,0 +1,6 @@ +export type PackageName = "host-contracts" | "gateway-contracts"; + +export const PACKAGE_CONFIG: Record = { + "host-contracts": { extraDeps: "forge soldeer install" }, + "gateway-contracts": {}, +}; diff --git a/ci/upgrade-report-hints.ts b/ci/upgrade-check/hints.ts similarity index 100% rename from ci/upgrade-report-hints.ts rename to ci/upgrade-check/hints.ts diff --git a/ci/upgrade-version-check-lib.ts b/ci/upgrade-check/lib.ts similarity index 100% rename from ci/upgrade-version-check-lib.ts rename to ci/upgrade-check/lib.ts diff --git a/ci/list-upgrades.ts b/ci/upgrade-check/list.ts similarity index 63% rename from ci/list-upgrades.ts rename to ci/upgrade-check/list.ts index 67ab75c213..ddddccc23e 100644 --- a/ci/list-upgrades.ts +++ b/ci/upgrade-check/list.ts @@ -1,22 +1,16 @@ #!/usr/bin/env bun import { execSync } from "child_process"; -import { mkdtempSync, rmSync } from "fs"; +import { existsSync, mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; -import { dirname, join, resolve } from "path"; +import { join, resolve } from "path"; -import { collectUpgradeVersionResults } from "./upgrade-version-check-lib"; -import { CONTRACT_HINTS, PACKAGE_CONSTRAINTS } from "./upgrade-report-hints"; - -type PackageName = "host-contracts" | "gateway-contracts"; - -const PACKAGE_CONFIG: Record = { - "host-contracts": { extraDeps: "forge soldeer install" }, - "gateway-contracts": {}, -}; +import { collectUpgradeVersionResults } from "./lib"; +import { CONTRACT_HINTS, PACKAGE_CONSTRAINTS } from "./hints"; +import { PACKAGE_CONFIG, type PackageName } from "./config"; function usage(): never { - console.error("Usage: bun ci/list-upgrades.ts --from [--to ] [--package host-contracts|gateway-contracts]"); + console.error("Usage: bun ci/upgrade-check/list.ts --from [--to ] [--package host-contracts|gateway-contracts]"); process.exit(1); } @@ -34,7 +28,7 @@ function parseArgs() { toRef = args[++idx]; } else if (arg === "--package") { const value = args[++idx] as PackageName; - if (value !== "host-contracts" && value !== "gateway-contracts") usage(); + if (!(value in PACKAGE_CONFIG)) usage(); packages.push(value); } else { usage(); @@ -46,12 +40,38 @@ function parseArgs() { return { fromRef, toRef, - packages: packages.length > 0 ? packages : (["host-contracts", "gateway-contracts"] as PackageName[]), + packages: packages.length > 0 ? packages : (Object.keys(PACKAGE_CONFIG) as PackageName[]), }; } +function formatExecError(error: unknown) { + if (!(error instanceof Error)) { + return String(error); + } + + const execError = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer }; + const stdout = execError.stdout ? String(execError.stdout).trim() : ""; + const stderr = execError.stderr ? String(execError.stderr).trim() : ""; + const details = [stderr, stdout].filter(Boolean).join("\n"); + if (!details) { + return execError.message; + } + + const maxLen = 2000; + return details.length > maxLen ? `${details.slice(-maxLen)}` : details; +} + function run(cmd: string, cwd: string) { - execSync(cmd, { cwd, stdio: "inherit", env: { ...process.env, NO_COLOR: "1" } }); + try { + execSync(cmd, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: { ...process.env, NO_COLOR: "1" }, + }); + } catch (error) { + throw new Error(`Command failed in ${cwd}: ${cmd}\n${formatExecError(error)}`); + } } function addWorktree(repoRoot: string, path: string, ref: string) { @@ -71,7 +91,7 @@ function preparePackage(currentRepoRoot: string, targetRoot: string, baselineRoo } run("make ensure-addresses", targetDir); run("make ensure-addresses", baselineDir); - run(`bun ci/merge-address-constants.ts "${join(baselineDir, "addresses")}" "${join(targetDir, "addresses")}"`, currentRepoRoot); + run(`bun ci/shared/merge-address-constants.ts "${join(baselineDir, "addresses")}" "${join(targetDir, "addresses")}"`, currentRepoRoot); run(`cp "${join(targetDir, "foundry.toml")}" "${join(baselineDir, "foundry.toml")}"`, currentRepoRoot); } @@ -128,7 +148,7 @@ function printPackageReport(pkg: PackageName, repoRoot: string, baselineRoot: st } const { fromRef, toRef, packages } = parseArgs(); -const repoRoot = resolve(dirname(import.meta.dir)); +const repoRoot = resolve(import.meta.dir, "../.."); const tempRoot = mkdtempSync(join(tmpdir(), "fhevm-upgrade-report-")); const baselineRoot = join(tempRoot, "baseline"); const targetRoot = toRef ? join(tempRoot, "target") : repoRoot; @@ -143,9 +163,26 @@ try { preparePackage(repoRoot, targetRoot, baselineRoot, pkg); printPackageReport(pkg, targetRoot, baselineRoot); } +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nUpgrade report failed: ${message}`); + process.exitCode = 1; } finally { - try { - run("git worktree prune", repoRoot); - } catch {} + if (toRef && existsSync(targetRoot)) { + try { + run(`git worktree remove --force "${targetRoot}"`, repoRoot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to clean target worktree: ${message}`); + } + } + if (existsSync(baselineRoot)) { + try { + run(`git worktree remove --force "${baselineRoot}"`, repoRoot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to clean baseline worktree: ${message}`); + } + } rmSync(tempRoot, { recursive: true, force: true }); }