Skip to content

Commit 6e65afc

Browse files
authored
feat(common): add ABI compatibility checks and reorganize CI scripts (#2182)
* feat(ci): add contract ABI compatibility checks * fix(ci): improve abi compat script output * fix(contracts): allow accepted ABI deltas in CI * fix(contracts): fail local abi wrapper on setup errors * fix(contracts): document workflow permissions * fix(contracts): remove abi check note from readme * docs(common): add ci readme for abi checks * fix(contracts): explain workflow default permissions * refactor(contracts): group abi compat scripts under ci * refactor(contracts): remove old abi compat script paths * fix(contracts): update upgrade check abi helper path * fix(contracts): correct nested abi compat repo root * fix(ci): correct broken import in abi-compat exceptions * refactor(ci): move merge-address-constants to ci/shared Used by both abi-compat and upgrade-check subsystems. * refactor(ci): group upgrade-check scripts under ci/upgrade-check Mirrors the ci/abi-compat/ structure: check.ts (CI entrypoint), lib.ts (core logic), list.ts (local multi-package report), hints.ts (domain config). * fix(ci): harden upgrade-check list.ts error handling Capture exec errors with truncated output instead of swallowing them. Clean up worktrees explicitly on failure instead of relying on prune. Matches the pattern established in abi-compat/list.ts. * refactor(ci): extract upgrade-check config to match abi-compat pattern * docs(ci): update readme with upgrade-check and shared entries * fix(ci): use inline permission comments for zizmor compliance * fix(ci): remove --force from forge inspect and fix minor code smells - Remove --force flag from forge inspect in abi-compat/lib.ts to avoid redundant recompilation (4x per side per package). - Simplify printPackageReport return in abi-compat/list.ts. - Derive valid package names from PACKAGE_CONFIG in upgrade-check parseArgs instead of hardcoding strings. * fix(ci): restore --force on forge inspect to avoid stale cache Without --force, a prior failed compilation (e.g. before address constants are generated) leaves error artifacts in forge's cache. Subsequent runs reuse the cached failure even after the source files are fixed. Reproduced by: forge clean, attempt compilation without addresses (fails), generate addresses, run check — forge returns the cached error for Decryption while other contracts work. * fix(ci): extract JSON from forge stdout to handle compilation preamble Forge may prepend compilation progress text to stdout on the first invocation in a clean directory. This caused Decryption (the first contract checked) to fail JSON.parse in CI while subsequent contracts succeeded from cache. Extract the JSON array from the output instead of parsing the whole string, matching the approach used by the upgrade-check bytecode extraction. * fix(common): include deployed ABI compat contracts * chore(common): retrigger ci * refactor(common): derive abi compat coverage from manifests * fix(ci): log abi inspect parse failures
1 parent b174610 commit 6e65afc

File tree

15 files changed

+828
-33
lines changed

15 files changed

+828
-33
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
name: contracts-abi-compat-check
2+
3+
# Default to no token permissions at the workflow level; each job opts into the
4+
# minimum read scopes it needs.
5+
permissions: {}
6+
7+
on:
8+
pull_request:
9+
10+
env:
11+
ABI_COMPAT_FROM_TAG: v0.11.1
12+
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
16+
17+
jobs:
18+
check-changes:
19+
name: contracts-abi-compat-check/check-changes
20+
permissions:
21+
contents: read # Required by actions/checkout to clone the repository
22+
pull-requests: read # Required by dorny/paths-filter to inspect changed files
23+
runs-on: ubuntu-latest
24+
outputs:
25+
packages: ${{ steps.filter.outputs.changes }}
26+
steps:
27+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
28+
with:
29+
persist-credentials: "false"
30+
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36
31+
id: filter
32+
with:
33+
filters: |
34+
host-contracts:
35+
- .github/workflows/contracts-abi-compat-check.yml
36+
- ci/abi-compat/**
37+
- ci/shared/**
38+
- host-contracts/**
39+
gateway-contracts:
40+
- .github/workflows/contracts-abi-compat-check.yml
41+
- ci/abi-compat/**
42+
- ci/shared/**
43+
- gateway-contracts/**
44+
45+
check:
46+
name: contracts-abi-compat-check/${{ matrix.package }}
47+
needs: check-changes
48+
if: ${{ needs.check-changes.outputs.packages != '[]' }}
49+
permissions:
50+
contents: read # Required by actions/checkout to clone the PR branch and baseline tag
51+
runs-on: ubuntu-latest
52+
strategy:
53+
fail-fast: false
54+
matrix:
55+
package: ${{ fromJSON(needs.check-changes.outputs.packages) }}
56+
include:
57+
- package: host-contracts
58+
extra-deps: forge soldeer install
59+
- package: gateway-contracts
60+
extra-deps: ""
61+
steps:
62+
- name: Checkout PR branch
63+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
64+
with:
65+
persist-credentials: "false"
66+
67+
- name: Checkout baseline
68+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
69+
with:
70+
ref: ${{ env.ABI_COMPAT_FROM_TAG }}
71+
path: baseline
72+
persist-credentials: "false"
73+
74+
- name: Install Bun
75+
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
76+
77+
- name: Install Foundry
78+
uses: foundry-rs/foundry-toolchain@82dee4ba654bd2146511f85f0d013af94670c4de
79+
80+
- name: Install PR dependencies
81+
working-directory: ${{ matrix.package }}
82+
run: npm ci
83+
84+
- name: Install baseline dependencies
85+
working-directory: baseline/${{ matrix.package }}
86+
run: npm ci
87+
88+
- name: Install Forge dependencies
89+
if: matrix.extra-deps != ''
90+
env:
91+
PACKAGE: ${{ matrix.package }}
92+
EXTRA_DEPS: ${{ matrix.extra-deps }}
93+
run: |
94+
(cd "$PACKAGE" && $EXTRA_DEPS)
95+
(cd "baseline/$PACKAGE" && $EXTRA_DEPS)
96+
97+
- name: Setup compilation
98+
env:
99+
PACKAGE: ${{ matrix.package }}
100+
run: |
101+
(cd "$PACKAGE" && make ensure-addresses)
102+
(cd "baseline/$PACKAGE" && make ensure-addresses)
103+
bun ci/shared/merge-address-constants.ts "baseline/$PACKAGE/addresses" "$PACKAGE/addresses"
104+
cp "$PACKAGE/foundry.toml" "baseline/$PACKAGE/foundry.toml"
105+
106+
- name: Run ABI compatibility check
107+
env:
108+
PACKAGE: ${{ matrix.package }}
109+
run: bun ci/abi-compat/check.ts "baseline/$PACKAGE" "$PACKAGE" "$PACKAGE"

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,13 @@ jobs:
3434
filters: |
3535
host-contracts:
3636
- .github/workflows/contracts-upgrade-version-check.yml
37-
- ci/check-upgrade-versions.ts
38-
- ci/merge-address-constants.ts
39-
- ci/upgrade-version-check-lib.ts
37+
- ci/upgrade-check/**
38+
- ci/shared/**
4039
- host-contracts/**
4140
gateway-contracts:
4241
- .github/workflows/contracts-upgrade-version-check.yml
43-
- ci/check-upgrade-versions.ts
44-
- ci/merge-address-constants.ts
45-
- ci/upgrade-version-check-lib.ts
42+
- ci/upgrade-check/**
43+
- ci/shared/**
4644
- gateway-contracts/**
4745
4846
check:
@@ -110,11 +108,11 @@ jobs:
110108
# the full union of constants with consistent values (PR wins for shared).
111109
(cd "$PACKAGE" && make ensure-addresses)
112110
(cd "baseline/$PACKAGE" && make ensure-addresses)
113-
bun ci/merge-address-constants.ts "baseline/$PACKAGE/addresses" "$PACKAGE/addresses"
111+
bun ci/shared/merge-address-constants.ts "baseline/$PACKAGE/addresses" "$PACKAGE/addresses"
114112
# Use PR's foundry.toml for both so compiler settings match (cbor_metadata, bytecode_hash)
115113
cp "$PACKAGE/foundry.toml" "baseline/$PACKAGE/foundry.toml"
116114
117115
- name: Run upgrade version check
118116
env:
119117
PACKAGE: ${{ matrix.package }}
120-
run: bun ci/check-upgrade-versions.ts "baseline/$PACKAGE" "$PACKAGE"
118+
run: bun ci/upgrade-check/check.ts "baseline/$PACKAGE" "$PACKAGE"

ci/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# CI Scripts
2+
3+
- [`abi-compat/`](./abi-compat/README.md): contract ABI compatibility checks
4+
- [`upgrade-check/`](./upgrade-check/): contract upgrade version checks
5+
- [`shared/`](./shared/): utilities shared across CI subsystems

ci/abi-compat/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# ABI Compatibility
2+
3+
ABI coverage is derived from each package's `upgrade-manifest.json`.
4+
Stable-surface policy lives in [`config.ts`](./config.ts): initializer, ownership, upgrade,
5+
and other intentionally non-public entrypoints are excluded there.
6+
7+
Compare the stable contract ABI surface between two refs locally with:
8+
9+
```bash
10+
bun ci/abi-compat/list.ts --from v0.11.1 --to v0.12.0-0
11+
```
12+
13+
Limit the scope with:
14+
15+
```bash
16+
bun ci/abi-compat/list.ts --from v0.11.1 --to v0.12.0-0 --package host-contracts
17+
bun ci/abi-compat/list.ts --from v0.11.1 --to v0.12.0-0 --package gateway-contracts
18+
```
19+
20+
Use the lower-level checker when both package directories are already prepared:
21+
22+
```bash
23+
bun ci/abi-compat/check.ts <baseline-pkg-dir> <target-pkg-dir> <host-contracts|gateway-contracts>
24+
```

ci/abi-compat/check.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env bun
2+
import { PACKAGE_CONFIG, type PackageName } from "./config";
3+
import { collectAbiCompatResults } from "./lib";
4+
5+
const [baselineDir, targetDir, pkg] = process.argv.slice(2) as [
6+
string | undefined,
7+
string | undefined,
8+
PackageName | undefined,
9+
];
10+
11+
if (!baselineDir || !targetDir || !pkg || !(pkg in PACKAGE_CONFIG)) {
12+
console.error(
13+
"Usage: bun ci/abi-compat/check.ts <baseline-pkg-dir> <target-pkg-dir> <host-contracts|gateway-contracts>",
14+
);
15+
process.exit(1);
16+
}
17+
18+
const results = collectAbiCompatResults(baselineDir, targetDir, pkg);
19+
let errors = 0;
20+
21+
for (const result of results) {
22+
console.log(`::group::Checking ${result.name}`);
23+
try {
24+
if (!result.baselineExists) {
25+
console.log(`Skipping ${result.name} (new contract, not in baseline)`);
26+
continue;
27+
}
28+
29+
console.log(
30+
`${result.name}: ${result.baselineStableCount} stable baseline ABI entries, ${result.targetStableCount} stable target ABI entries`,
31+
);
32+
33+
for (const error of result.errors) {
34+
console.error(`::error::${error}`);
35+
errors++;
36+
}
37+
38+
for (const signature of result.missing) {
39+
console.error(`::error::${result.name} missing stable ABI signature: ${signature}`);
40+
errors++;
41+
}
42+
43+
if (result.allowedMissing.length > 0) {
44+
console.log(`${result.name}: accepted stable ABI exceptions`);
45+
for (const signature of result.allowedMissing) {
46+
console.log(` ~ ${signature}`);
47+
}
48+
}
49+
50+
if (result.added.length > 0) {
51+
console.log(`${result.name}: added stable ABI entries`);
52+
for (const signature of result.added) {
53+
console.log(` + ${signature}`);
54+
}
55+
}
56+
57+
if (result.errors.length === 0 && result.missing.length === 0 && result.added.length === 0) {
58+
console.log(`${result.name}: no stable ABI changes`);
59+
}
60+
} finally {
61+
console.log("::endgroup::");
62+
}
63+
}
64+
65+
if (errors > 0) {
66+
console.error(`::error::ABI compatibility check failed with ${errors} error(s)`);
67+
process.exit(1);
68+
}
69+
70+
console.log("All contracts passed ABI compatibility checks");

ci/abi-compat/config.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export type PackageName = "host-contracts" | "gateway-contracts";
2+
3+
export const EXCLUDED_MODIFIERS = new Set([
4+
"onlyOwner",
5+
"onlyACLOwner",
6+
"onlyGatewayOwner",
7+
"onlyFromEmptyProxy",
8+
"onlyPauser",
9+
"onlyCoprocessorTxSender",
10+
"onlyKmsTxSender",
11+
"onlyRegisteredHostChain",
12+
"onlyHandleFromRegisteredHostChain",
13+
"onlyDecryptionContract",
14+
"onlyInputVerificationContract",
15+
]);
16+
17+
export const EXCLUDED_FUNCTION_PATTERNS = [
18+
/^initialize/,
19+
/^reinitializeV\d+$/,
20+
/^acceptOwnership$/,
21+
/^owner$/,
22+
/^transferOwnership$/,
23+
/^upgradeToAndCall$/,
24+
];
25+
26+
// ABI coverage is derived from each package's upgrade-manifest.json.
27+
// Keep only stable-surface exclusions here.
28+
export const EXCLUDED_CONTRACT_FUNCTION_PATTERNS: Record<string, RegExp[]> = {
29+
HCULimit: [/^checkHCUFor/],
30+
};
31+
32+
export const PACKAGE_CONFIG: Record<
33+
PackageName,
34+
{
35+
extraDeps?: string;
36+
}
37+
> = {
38+
"host-contracts": {
39+
extraDeps: "forge soldeer install",
40+
},
41+
"gateway-contracts": {},
42+
};

ci/abi-compat/exceptions.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { PackageName } from "./config";
2+
3+
export const ABI_COMPAT_EXCEPTIONS: Partial<Record<PackageName, Partial<Record<string, string[]>>>> = {
4+
"host-contracts": {
5+
ACL: ["error ExpirationDateBeforeOneHour()"],
6+
KMSVerifier: ["event NewContextSet(address[],uint256)"],
7+
},
8+
"gateway-contracts": {
9+
Decryption: [
10+
"error AccountNotAllowedToUseCiphertext(bytes32,address)",
11+
"error PublicDecryptNotAllowed(bytes32)",
12+
"error UserDecryptionNotDelegated(uint256,address,address,address)",
13+
"function isDelegatedUserDecryptionReady((address,address),(bytes32,address)[],bytes) returns (bool)",
14+
],
15+
GatewayConfig: [
16+
"event InitializeGatewayConfig((string,string),(uint256,uint256,uint256,uint256,uint256),(address,address,string,string)[],(address,address,string)[],(address,address,bytes)[])",
17+
"event UpdateKmsNodes((address,address,string,string)[],uint256,uint256,uint256,uint256)",
18+
"function getPublicDecryptionThreshold() returns (uint256)",
19+
"function getUserDecryptionThreshold() returns (uint256)",
20+
],
21+
},
22+
};

0 commit comments

Comments
 (0)