Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/abi-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ on:
paths:
- 'contracts/src/**'
- 'scripts/gen-abi-docs.mjs'
- 'abis/**'
- 'scripts/check-abi-bundle.mjs'
- 'docs/abi/**'
- 'abis/**'
- 'scripts/check-abi-bundle.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/abi-docs.yml'
Expand All @@ -19,6 +23,8 @@ on:
- 'contracts/src/**'
- 'scripts/gen-abi-docs.mjs'
- 'docs/abi/**'
- 'abis/**'
- 'scripts/check-abi-bundle.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/abi-docs.yml'
Expand Down Expand Up @@ -52,3 +58,10 @@ jobs:

- name: Verify ABI docs are up to date (pnpm gen:abi-docs:check)
run: pnpm gen:abi-docs:check

# gen:abi-docs:check only recomputes docs/abi/*.md. It never reads
# abis/*.json -- the bundle sync_to_sdk.sh copies into aastar-sdk. That is
# how abis/BLSAggregator.json drifted four functions behind the contract
# for six days with this job green (#410, #411).
- name: ABI bundles match the compiled contracts
run: node scripts/check-abi-bundle.mjs
64 changes: 64 additions & 0 deletions .github/workflows/merge-preflight.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# =============================================================================
# The preflight has to RUN, not merely exist.
#
# scripts/merge-preflight.sh was added as a command someone would remember to
# type — and the failure it guards is forgetting. #408 was merged by someone who
# knew the rule, minutes after reading a `gh pr view` that had already gone
# stale. A rule that is "sometimes done" is done "often forgotten". Raised by
# pr-daemon on #412.
#
# Both legs are observable at push time: the approval-vs-head comparison is why
# it re-reads the head on every push rather than trusting an earlier reading.
# NOT a required check, deliberately. Its three legs are each enforced by GitHub
# itself — required contexts for the checks, dismiss_stale_reviews for
# approval==head, reviewDecision for CHANGES_REQUESTED — and two of them this job
# cannot even verify from a GITHUB_TOKEN. Listing it as required would have made
# a green tick claim the approval legs were checked here. They are not.
#
# It runs to REPORT, and to make the script exercised rather than merely present
# (which was its original gap). The refusal lives in branch protection and in the
# strict pre-merge run of the same script.
# =============================================================================
name: merge-preflight-report

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
pull_request_review:
types: [submitted, dismissed]

permissions:
contents: read
pull-requests: read
checks: read
statuses: read

jobs:
preflight-report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# --ci, because a required check has to be able to go green. Without it
# this job can NEVER pass: a fresh PR has no approval yet, its sibling
# checks are still running while it runs, and it counted ITSELF — first as
# a pending check, then, once red, as a failing one. Verified on chain: on
# cb0af21d `preflight` was the ONLY failure among eleven runs, and its own
# log read `FAIL still running: ..., preflight, ...`.
#
# No SELF_NAME here on purpose. The run DERIVES its own check-run name from
# GITHUB_RUN_ID (a check run's details_url is .../runs/<id>/job/<id>), so
# renaming the job or adding a `name:` cannot desynchronise it. An earlier
# version passed the name in and asserted it existed — which proves a run
# by that name exists, not that it is this one. This head carried TWO runs
# named `preflight`.
#
# --ci reports the two TRANSIENT conditions (no approval yet, siblings
# still running) without failing on them, and still fails on what is never
# transient: an approval naming a DIFFERENT sha (#408) and a check that
# actually failed (#400/#405). Re-approval re-runs this via
# pull_request_review, so the approval leg recovers without a push.
- name: Approval names this SHA, and nothing is failing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: ./scripts/merge-preflight.sh --ci ${{ github.event.pull_request.number }}
87 changes: 87 additions & 0 deletions scripts/check-abi-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env node
// =============================================================================
// check-abi-bundle.mjs
//
// `gen-abi-docs.mjs --check` recomputes docs/abi/*.md and nothing else. It never
// reads abis/*.json — the bundle sync_to_sdk.sh copies into aastar-sdk, i.e. the
// artifact downstream actually imports. So the file with consumers sat outside
// the gate while the human-readable docs sat inside it, and abis/BLSAggregator.json
// drifted four functions behind the contract for six days with CI green. Raised
// by pr-daemon; issue #411.
//
// Names are not enough. #400 added a field to an EXISTING getter
// (guardianSlashCases, 7 outputs -> 8) without adding or removing a function, so a
// name-level diff sees nothing. This compares the full shape: inputs, outputs,
// stateMutability.
// =============================================================================
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join } from "node:path";

const ROOT = process.cwd();
const ABIS = join(ROOT, "abis");
const OUT = join(ROOT, "out");

const shape = (f) => JSON.stringify({
t: f.type, n: f.name ?? "",
i: (f.inputs ?? []).map((x) => x.type),
o: (f.outputs ?? []).map((x) => x.type),
m: f.stateMutability ?? "",
});

// Only first-party contracts are comparable. abis/ also carries EntryPoint,
// SimpleAccount and SimpleAccountFactory, which are account-abstraction v0.7
// artifacts deliberately pinned to what is DEPLOYED (EntryPoint v0.7 lives at
// 0x0000000071727De22E5E9d8BAf0edAc6f37da032). Comparing those against whatever
// `out/` happens to hold reports drift that is intentional — the first version
// of this script did exactly that and flagged all three. A gate that cries wolf
// on pinned externals gets ignored, and then it is not a gate.
const FIRST_PARTY = new Set(
walkSol(join(ROOT, "contracts", "src")).map((f) => f.replace(/\.sol$/, ""))
);
function walkSol(dir) {
if (!existsSync(dir)) return [];
return readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? walkSol(join(dir, e.name)) : e.name.endsWith(".sol") ? [e.name] : []
);
}

function compiledAbi(name) {
const p = join(OUT, `${name}.sol`, `${name}.json`);
return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")).abi : null;
}

let stale = 0, checked = 0, skipped = [], external = [];
for (const file of readdirSync(ABIS).filter((f) => f.endsWith(".json"))) {
const name = file.replace(/\.json$/, "");
if (name === "abi.config") continue;
const raw = JSON.parse(readFileSync(join(ABIS, file), "utf8"));
const committed = Array.isArray(raw) ? raw : raw.abi;
const compiled = compiledAbi(name);
// No artifact means this bundle has no contract to compare against. Say so
// rather than counting it as agreement: an empty comparison and a passing one
// are the same reading otherwise.
if (!FIRST_PARTY.has(name)) { external.push(name); continue; }
// A first-party bundle whose artifact is missing is NOT a pass. The first
// version pushed it to `skipped` and exited 0, so a partial or stale `out/`
// turned every uncompared bundle into a silent agreement — the same
// fail-open this script exists to close, reproduced inside it.
if (!compiled) { console.error(`FAIL: abis/${file} — no compiled artifact at out/${name}.sol/${name}.json`); stale++; continue; }
if (!committed) { console.error(`FAIL: abis/${file} — unreadable or empty ABI`); stale++; continue; }
checked++;
const c = new Set(committed.map(shape));
const o = new Set(compiled.map(shape));
const missing = [...o].filter((x) => !c.has(x));
const extra = [...c].filter((x) => !o.has(x));
if (missing.length || extra.length) {
stale++;
console.error(`STALE: abis/${file}`);
for (const m of missing.slice(0, 6)) console.error(` only in compiled: ${m}`);
for (const e of extra.slice(0, 6)) console.error(` only in abis/ : ${e}`);
}
}
console.log(`compared ${checked} bundles against out/`);
if (skipped.length) console.log(`no artifact, NOT compared: ${skipped.join(", ")}`);
if (external.length) console.log(`external / pinned to a deployment, NOT compared: ${external.join(", ")}`);
if (checked === 0) { console.error("FAIL: nothing was compared — run `forge build` first"); process.exit(2); }
if (stale) { console.error(`\n${stale} bundle(s) stale — run scripts/extract_v3_abis.sh and commit.`); process.exit(1); }
console.log("abis/ matches the compiled contracts (full shape, not just names)");
Loading
Loading