From 28d1c0e9619ed3c98c5f4a6dba0e18032c03a0b0 Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Tue, 29 Sep 2026 15:20:22 +0530 Subject: [PATCH 1/9] fix: release metadata comparison for the contract itself Signed-off-by: GHkrishna --- RELEASE_ARTIFACTS.md | 4 +- scripts/js/release-metadata.mjs | 76 +++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/RELEASE_ARTIFACTS.md b/RELEASE_ARTIFACTS.md index 1a480c93..724f28dd 100644 --- a/RELEASE_ARTIFACTS.md +++ b/RELEASE_ARTIFACTS.md @@ -68,12 +68,14 @@ The release surface is decided in `.github/abi-contracts.txt` so a contract reac ```json { "version": "v1.2.3", + "hashScheme": 2, "build": { "solcVersion": "0.8.34+commit...", "optimizer": {}, "viaIr": true, "evmVersion": "cancun", "foundryLockSha256": "..." }, "hashes": { "DotnsRegistrar": "0x..." } } ``` -- `hashes` maps each deployable contract to the keccak256 of its built runtime bytecode with the trailing CBOR metadata stripped, so a comment-only edit does not read as a code change. Comparing two releases' files tells you exactly which contracts a release changed; an upgrade must cover that whole set before the release may be declared on a network (see `DEPLOYMENT_CHECKLIST.md`). +- `hashes` maps each deployable contract to the keccak256 of its built runtime bytecode with the trailing CBOR metadata stripped, so a comment-only edit does not read as a code change. A contract that deploys other contracts with `new` (such as `StoreFactory`) also contains their creation code, and each copy ends with that contract's own metadata. The hash inside those copies is set to zeros before hashing, so a comment-only edit to `LabelStore` does not make `StoreFactory` look changed either. Comparing two releases' files tells you exactly which contracts a release changed; an upgrade must cover that whole set before the release may be declared on a network (see `DEPLOYMENT_CHECKLIST.md`). +- `hashScheme` says how `hashes` were computed. `2` is the method described above. `1` skips the zeroing of embedded metadata, and files without `hashScheme` (from releases made before it was added) use it. The two only give different hashes for contracts that deploy other contracts with `new`, so only compare two files that use the same scheme. `release-metadata.mjs changedset` does this for you: it hashes the current build with the previous file's scheme. - These are artifact-side hashes, for comparing builds with builds. A deployed contract hashes differently on chain (its bytecode carries the metadata and any immutable values), so compare this file against another release's copy of it. - `build` records the toolchain inputs. The same source under a different toolchain hashes differently, and that difference is a real code change on chain, so treat the hashes as comparable only alongside their build inputs. diff --git a/scripts/js/release-metadata.mjs b/scripts/js/release-metadata.mjs index 5fd2975b..72964b09 100644 --- a/scripts/js/release-metadata.mjs +++ b/scripts/js/release-metadata.mjs @@ -166,15 +166,67 @@ function stripCborMetadata(name, hex) { return `0x${code.slice(0, stripped * 2)}`; } +// The metadata at the end is not the only metadata in the code. A contract that deploys other +// contracts (with `new`, for example) carries a copy of their creation code, and each copy ends +// with that contract's own metadata. StoreFactory is an example: it contains LabelStore's +// creation code, so a comment-only edit to LabelStore changes a few bytes inside StoreFactory. +// +// To ignore that, the hash (digest) inside each embedded metadata block is set to zeros. The +// code keeps its length, so nothing else moves, and the compiler version bytes are kept. A real +// change to an embedded contract's code, or to the compiler, still changes the hash. +// +// A block is found by its exact shape, not by where it sits: {"ipfs": <34-byte hash>, "solc": +// <3-byte version>} followed by its length, 0x0033. That is what solc writes with foundry's +// default `bytecode_hash = "ipfs"`. The older `bzzr1` shape is handled too. With +// `bytecode_hash = "none"` there is no hash to clear. Normal code does not contain these exact +// bytes by chance, and a match only counts if it starts on a whole byte. +const EMBEDDED_METADATA_SHAPES = [ + { prefix: "a264697066735822", digestHexLength: 68, suffix: "0033" }, + { prefix: "a265627a7a72315820", digestHexLength: 64, suffix: "0032" }, +]; + +function zeroEmbeddedMetadataDigests(hex) { + let code = hex.toLowerCase(); + for (const { prefix, digestHexLength, suffix } of EMBEDDED_METADATA_SHAPES) { + const shape = new RegExp( + `${prefix}[0-9a-f]{${digestHexLength}}64736f6c6343[0-9a-f]{6}${suffix}`, + "g", + ); + const zeros = "0".repeat(digestHexLength); + let match; + while ((match = shape.exec(code)) !== null) { + // `code` starts with "0x", so every byte starts at an even index. A match at an odd index + // starts in the middle of a byte, so it is not a real block. Keep searching from the next + // character. + if (match.index % 2 !== 0) { + shape.lastIndex = match.index + 1; + continue; + } + const digestStart = match.index + prefix.length; + code = code.slice(0, digestStart) + zeros + code.slice(digestStart + digestHexLength); + } + } + return code; +} + // keccak256 via `cast keccak`, keeping the script dependency-free like the chain reads. function keccakHex(hex) { return cast(["keccak", hex]); } +// Saved in codehashes.json as `hashScheme`, so that `changedset` can hash the current build the +// same way the previous file was hashed. Comparing hashes made in two different ways would show +// changes that are not there. +// 1: the metadata at the end is removed. Files written before `hashScheme` existed use this. +// 2: the same, and the hashes inside embedded metadata are set to zeros as well. +// Both give the same result for a contract that does not deploy other contracts. +const HASH_SCHEME = 2; +const HASH_SCHEMES = [1, 2]; + // Stripped-metadata hash of each deployable contract's built runtime bytecode. These are // artifact-side hashes for comparing builds with builds (the changed-set); they are never // compared against on-chain hashes, which live in a different domain (see `verify`). -function builtCodehashes() { +function builtCodehashes(scheme = HASH_SCHEME) { const { contracts } = classifyContracts(readContractNames()); const hashes = {}; for (const name of contracts) { @@ -183,7 +235,8 @@ function builtCodehashes() { ); const runtime = artefact?.deployedBytecode?.object; if (!runtime || runtime === "0x") fail(`${name}: no deployed bytecode in the artefact`); - hashes[name] = keccakHex(stripCborMetadata(name, runtime)); + const stripped = stripCborMetadata(name, runtime); + hashes[name] = keccakHex(scheme >= 2 ? zeroEmbeddedMetadataDigests(stripped) : stripped); } return hashes; } @@ -216,7 +269,23 @@ function changedset(args) { const previous = JSON.parse(readFileSync(resolve(process.cwd(), args.previous), "utf8")); const previousHashes = previous?.hashes; if (!previousHashes) fail(`${args.previous} has no 'hashes' map`); - const current = builtCodehashes(); + // Hash the build the same way the previous file was hashed. Otherwise a change in how the + // hash is computed would look like a change in the code. + const scheme = previous.hashScheme ?? 1; + if (!HASH_SCHEMES.includes(scheme)) { + fail( + `${args.previous} uses hashScheme ${scheme}; this script knows ${HASH_SCHEMES.join(", ")}`, + ); + } + if (scheme < HASH_SCHEME) { + // Printed to stderr, because stdout is the list that upgrade tooling reads. + console.error( + `[release-metadata] ${args.previous} uses hashScheme ${scheme}, so this build is hashed ` + + `the same way. A contract that deploys other contracts with \`new\` is listed if any ` + + `contract it deploys changed at all, even if only a comment changed.`, + ); + } + const current = builtCodehashes(scheme); // Union, not just the current set: a contract only in the previous release was removed and a // contract only in this one is new. Neither is coverable by an in-place upgrade, so both must // surface and force the coverage gate to refuse rather than dropping out of the diff. @@ -413,6 +482,7 @@ function build(args) { // pre-release tags, and an upgrade diffs its build against the previous release's file. writeJson(join(outDir, "codehashes.json"), { version: tag, + hashScheme: HASH_SCHEME, build: buildInputs(), hashes: builtCodehashes(), }); From 0043be4338000bbad083a059ed1adc8183af6bdd Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Tue, 29 Sep 2026 15:26:07 +0530 Subject: [PATCH 2/9] fix: releases maintain breaking changes order in the release metadta Signed-off-by: GHkrishna --- .github/workflows/publish-prerelease.yml | 34 ++- .github/workflows/publish-release.yml | 34 ++- scripts/js/release-metadata.mjs | 308 +++++++++++++++++++---- 3 files changed, 314 insertions(+), 62 deletions(-) diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index f4f73dad..43b94b8b 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -16,6 +16,9 @@ on: permissions: contents: write + # The release notes list the breaking changes that pull requests declare, so the job has to + # read the pull requests merged since the previous release. + pull-requests: read # Two runs for the same version would race to attach assets to the same draft. On a # dispatch `github.ref_name` is the branch, so key on the requested version instead. @@ -268,19 +271,30 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - # The previous published release's ABIs come from its zip; diffing against them - # announces every selector change in the body, most importantly a same-name - # signature change, which an old caller experiences as a bare revert. A previous - # release without the zip degrades to "no diff" rather than failing the release. + # The previous published release's ABIs come from its zip. Comparing with them lists + # every selector change in the body, breaking ones first. The most important one is a + # function that keeps its name but changes its signature, because an old caller then + # gets a bare revert. If the previous release has no zip, the body says there is + # nothing to compare with, and the release goes on. + # + # The pull requests merged since that release are read as well, so the breaking + # changes they describe (the "Breaking Changes" block of the pull request template) + # are listed first. If that read fails, this step fails. Notes that silently miss a + # breaking change are worse than a step that has to be re-run. PREV=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName 2>/dev/null || true) - prev_args=() - if [ -n "$PREV" ] && gh release download "$PREV" --repo "$GITHUB_REPOSITORY" \ - --pattern 'dotns-abis-*.zip' --dir prev-abis >/dev/null 2>&1; then - unzip -q -o prev-abis/dotns-abis-*.zip -d prev-abis - prev_args=(--previous prev-abis/abis --previous-tag "$PREV") + diff_args=() + if [ -n "$PREV" ]; then + bun scripts/js/release-metadata.mjs pulls --repo "$GITHUB_REPOSITORY" \ + --base "$PREV" --head "$GITHUB_SHA" --out "$RUNNER_TEMP/pulls.json" + diff_args=(--previous-tag "$PREV" --pulls "$RUNNER_TEMP/pulls.json") + if gh release download "$PREV" --repo "$GITHUB_REPOSITORY" \ + --pattern 'dotns-abis-*.zip' --dir prev-abis >/dev/null 2>&1; then + unzip -q -o prev-abis/dotns-abis-*.zip -d prev-abis + diff_args+=(--previous prev-abis/abis) + fi fi bun scripts/js/release-metadata.mjs abidiff --current release/abis \ - "${prev_args[@]}" --json release/abi-diff.json >> release-body.md + "${diff_args[@]}" --json release/abi-diff.json >> release-body.md - name: Create draft pre-release with artifacts uses: softprops/action-gh-release@v2 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 88cbbcb4..96913647 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,6 +16,9 @@ on: permissions: contents: write + # The release notes list the breaking changes that pull requests declare, so the job has to + # read the pull requests merged since the previous release. + pull-requests: read # Two runs for the same version would race to attach assets to the same draft. On a # dispatch `github.ref_name` is the branch, so key on the requested version instead. @@ -284,19 +287,30 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - # The previous published release's ABIs come from its zip; diffing against them - # announces every selector change in the body, most importantly a same-name - # signature change, which an old caller experiences as a bare revert. A previous - # release without the zip degrades to "no diff" rather than failing the release. + # The previous published release's ABIs come from its zip. Comparing with them lists + # every selector change in the body, breaking ones first. The most important one is a + # function that keeps its name but changes its signature, because an old caller then + # gets a bare revert. If the previous release has no zip, the body says there is + # nothing to compare with, and the release goes on. + # + # The pull requests merged since that release are read as well, so the breaking + # changes they describe (the "Breaking Changes" block of the pull request template) + # are listed first. If that read fails, this step fails. Notes that silently miss a + # breaking change are worse than a step that has to be re-run. PREV=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName 2>/dev/null || true) - prev_args=() - if [ -n "$PREV" ] && gh release download "$PREV" --repo "$GITHUB_REPOSITORY" \ - --pattern 'dotns-abis-*.zip' --dir prev-abis >/dev/null 2>&1; then - unzip -q -o prev-abis/dotns-abis-*.zip -d prev-abis - prev_args=(--previous prev-abis/abis --previous-tag "$PREV") + diff_args=() + if [ -n "$PREV" ]; then + bun scripts/js/release-metadata.mjs pulls --repo "$GITHUB_REPOSITORY" \ + --base "$PREV" --head "$GITHUB_SHA" --out "$RUNNER_TEMP/pulls.json" + diff_args=(--previous-tag "$PREV" --pulls "$RUNNER_TEMP/pulls.json") + if gh release download "$PREV" --repo "$GITHUB_REPOSITORY" \ + --pattern 'dotns-abis-*.zip' --dir prev-abis >/dev/null 2>&1; then + unzip -q -o prev-abis/dotns-abis-*.zip -d prev-abis + diff_args+=(--previous prev-abis/abis) + fi fi bun scripts/js/release-metadata.mjs abidiff --current release/abis \ - "${prev_args[@]}" --json release/abi-diff.json >> release-body.md + "${diff_args[@]}" --json release/abi-diff.json >> release-body.md - name: Create draft release with artifacts uses: softprops/action-gh-release@v2 diff --git a/scripts/js/release-metadata.mjs b/scripts/js/release-metadata.mjs index 72964b09..879b5a6b 100644 --- a/scripts/js/release-metadata.mjs +++ b/scripts/js/release-metadata.mjs @@ -5,16 +5,20 @@ // changelog --current [--previous ] release-note lines about address changes // [--previous-tag ] // changedset --previous contracts whose built code differs, one per line -// abidiff --current [--previous ] selector-level ABI diff for the release body +// pulls --repo --base pull requests merged since , as JSON +// --head --out for `abidiff --pulls` +// abidiff --current [--previous ] ABI diff for the release body // [--previous-tag ] [--json ] +// [--pulls ] // verify --network --rpc [--tag ] check a committed manifest against a chain // -// `build` and `changelog` run in both publish workflows; `validate` runs on pull requests, so a -// broken manifest fails there rather than at release time. `verify` stays out of the release -// path, which must work without reaching a chain; with `--tag` it also checks the chain's -// declared protocol version and code identity. `changedset` names exactly what a release -// changed, for upgrade tooling (which lives outside this repository) and for humans. No -// dependencies: `cast` does the chain reads and the hashing. +// `build`, `pulls` and `abidiff` run in both publish workflows, and `changelog` in the release +// one. `validate` runs on pull requests, so a broken manifest fails there rather than at release +// time. `verify` stays out of the release path, which must work without reaching a chain; with +// `--tag` it also checks the chain's declared protocol version and code identity. `changedset` +// names exactly what a release changed, for upgrade tooling (which lives outside this +// repository) and for humans. No dependencies: `cast` does the chain reads and the hashing, and +// `gh` reads the pull requests. import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; @@ -340,8 +344,8 @@ function readAbiDir(dir) { // Selector-level diff of one contract's ABI. A struct gaining a field is the case that // motivated this: same function name, different selector, an old caller gets a bare revert. So -// "changed" (same name, different signature set) is the highest-severity class and is reported -// before pure additions and removals. +// "changed" (same name, different signature set) is kept apart from plain additions and +// removals, and `abidiff` lists it first among the breaking changes. function diffAbi(previous, current) { const before = signaturesByKind(previous); const after = signaturesByKind(current); @@ -384,73 +388,290 @@ function diffAbi(previous, current) { return result; } -// Markdown fragment for the release body plus a machine-readable JSON asset. Prints "no -// changes" rather than nothing, so absence is a statement and not a gap; a missing previous -// release degrades the same way rather than failing the release. +// The pull request template's "Breaking Changes" block has some fixed lines: two checkboxes and +// a bold "Breaking changes:" label. They are not part of what the author wrote, so they are +// removed before the text is used. +const BREAKING_LABEL_LINE = /^\s*\*\*Breaking changes:?\*\*\s*$/i; +const BREAKING_TEMPLATE_LINES = [ + /^\s*[-*]\s*\[[ xX]\]\s*No breaking changes\s*$/i, + /^\s*[-*]\s*\[[ xX]\]\s*Breaking changes documented below\s*$/i, + BREAKING_LABEL_LINE, +]; + +// Returns the breaking change a pull request declares, or null if it declares none. +// +// The text is read from the "Breaking Changes" heading (or the bold "Breaking changes:" label) +// down to the next heading, without the template's fixed lines. A pull request is also treated +// as breaking when any of these is true, even if it has no text: +// * its title uses the conventional-commit `!`, like `feat!:` or `feat(store)!:` +// * it has the `breaking` label +// * the "Breaking change" or "Breaking changes documented below" box is ticked +// Any one signal is enough. Missing a real breaking change in the release notes costs far more +// than listing one that turns out to be harmless, so no single signal is trusted to be the only +// one. A pull request with a signal but no text is still listed, with a note saying so. +function declaredBreaking(pr) { + const body = String(pr.body ?? "") + .replace(/\r\n?/g, "\n") + .replace(//g, ""); + const lines = body.split("\n"); + const isHeading = (line) => /^\s{0,3}#{1,6}\s/.test(line); + // The heading has to say exactly "Breaking change(s)", so "Non-breaking changes" is not it. + const isBreakingHeading = (line) => /^\s{0,3}#{1,6}\s+breaking changes?:?\s*$/i.test(line); + const start = lines.findIndex( + (line) => isBreakingHeading(line) || BREAKING_LABEL_LINE.test(line), + ); + let notes = ""; + if (start !== -1) { + const rest = lines.slice(start + 1); + const end = rest.findIndex(isHeading); + notes = (end === -1 ? rest : rest.slice(0, end)) + .filter((line) => !BREAKING_TEMPLATE_LINES.some((pattern) => pattern.test(line))) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + // Authors often write a placeholder instead of leaving the block empty. + if (/^(none|n\/?a|no|-+|no breaking changes)\.?$/i.test(notes)) notes = ""; + } + const ticked = (label) => new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${label}\\s*$`, "im").test(body); + const flagged = + /^[a-z]+(\([^)]*\))?!:/i.test(pr.title ?? "") || + (pr.labels ?? []).includes("breaking") || + ticked("Breaking change") || + ticked("Breaking changes documented below"); + if (!notes && !flagged) return null; + return { number: pr.number, title: pr.title, url: pr.url, notes }; +} + +function declaredBreakingChanges(path) { + const prs = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8")); + if (!Array.isArray(prs)) fail(`${path} is not a list of pull requests; write it with \`pulls\``); + return prs + .map(declaredBreaking) + .filter(Boolean) + .sort((a, b) => a.number - b.number); +} + +// One list item per pull request. Its text is indented so that any lists or paragraphs the +// author wrote stay inside that item. +function declaredLines({ number, title, url, notes }) { + const body = notes + ? notes.split("\n").map((line) => (line === "" ? "" : ` ${line}`)) + : [" Marked as breaking; the description gives no details."]; + return [`- [#${number}](${url}) ${title}`, ...body]; +} + +// Writes the "ABI changes" part of the release body, and the same data as JSON. The most +// important changes come first: +// 1. Breaking changes: what pull requests declared, then changed function signatures, +// removed functions, contracts no longer published, and changed or removed events. +// 2. New contracts, new functions and new events. +// 3. Custom errors. A changed error only changes how a revert is decoded, not whether a call +// works. +// "Breaking" means something that used to work stops working: a call now reverts, or an +// indexer stops receiving an event. Pull requests can also declare behaviour changes that no +// ABI shows, which is why their text is included. +// +// When there is nothing to report, it says so, so an empty section is never mistaken for a +// missing one. A previous release without ABIs is reported the same way instead of failing. function abidiff(args) { if (!args.current) fail("abidiff needs --current "); const previousTag = args["previous-tag"] ?? "the previous release"; - const lines = []; const report = { previousTag: args["previous-tag"] ?? null, contracts: {} }; + const declared = args.pulls ? declaredBreakingChanges(args.pulls) : []; + if (args.pulls) report.declaredBreaking = declared; + + const breaking = { + functionsChanged: [], + functionsRemoved: [], + contractsRemoved: [], + events: [], + }; + const added = { contracts: [], functions: [], events: [] }; + const errors = []; + const code = (text) => `\`${text}\``; + const signatureList = (list) => list.map(code).join(", ") || "(none)"; if (!args.previous) { - lines.push("", "No earlier release carries ABIs to diff against."); report.previousUnavailable = true; } else { const currentAbis = readAbiDir(resolve(process.cwd(), args.current)); const previousAbis = readAbiDir(resolve(process.cwd(), args.previous)); - const changedLines = []; - const otherLines = []; - for (const [name, abi] of currentAbis) { + for (const name of [...currentAbis.keys()].sort()) { const previousAbi = previousAbis.get(name); if (!previousAbi) { - otherLines.push(`- \`${name}\`: new contract`); + added.contracts.push(`- ${code(name)}`); report.contracts[name] = { newContract: true }; continue; } - const diff = diffAbi(previousAbi, abi); + const diff = diffAbi(previousAbi, currentAbis.get(name)); if (Object.keys(diff).length === 0) continue; report.contracts[name] = diff; - for (const [kind, { changed, added, removed }] of Object.entries(diff)) { - for (const entry of changed) { - changedLines.push( - `- \`${name}\`: ${kind} \`${entry.name}\` changed signature: ` + - `${entry.was.map((s) => `\`${s}\``).join(", ") || "(none)"} is now ` + - `${entry.now.map((s) => `\`${s}\``).join(", ") || "(none)"}`, + for (const [kind, entries] of Object.entries(diff)) { + const changedLines = entries.changed.map( + (entry) => + `- ${code(name)}: ${code(entry.name)} changed signature: ` + + `${signatureList(entry.was)} is now ${signatureList(entry.now)}`, + ); + const addedLines = entries.added.map((signature) => `- ${code(name)}: ${code(signature)}`); + const removedLines = entries.removed.map( + (signature) => `- ${code(name)}: ${code(signature)} removed`, + ); + if (kind === "function") { + breaking.functionsChanged.push(...changedLines); + // Listed under a "Removed functions" heading, so the line needs no "removed". + breaking.functionsRemoved.push( + ...entries.removed.map((signature) => `- ${code(name)}: ${code(signature)}`), + ); + added.functions.push(...addedLines); + } else if (kind === "event") { + breaking.events.push(...changedLines, ...removedLines); + added.events.push(...addedLines); + } else { + errors.push( + ...changedLines, + ...entries.added.map((signature) => `- ${code(name)}: ${code(signature)} added`), + ...removedLines, ); - } - for (const signature of added) otherLines.push(`- \`${name}\`: ${kind} \`${signature}\` added`); - for (const signature of removed) { - otherLines.push(`- \`${name}\`: ${kind} \`${signature}\` removed`); } } } - for (const name of previousAbis.keys()) { + for (const name of [...previousAbis.keys()].sort()) { if (!currentAbis.has(name)) { - otherLines.push(`- \`${name}\`: no longer published`); + breaking.contractsRemoved.push(`- ${code(name)}`); report.contracts[name] = { removedContract: true }; } } + } + const breakingParts = [ + [ + "**Declared in pull requests.** These can include behaviour changes an ABI does not show:", + declared.flatMap(declaredLines), + ], + [ + "**Changed function signatures.** Existing callers get a bare revert until they update:", + breaking.functionsChanged, + ], + ["**Removed functions.** Calls to these now revert:", breaking.functionsRemoved], + [ + "**Contracts no longer published.** Their ABIs are not in this release:", + breaking.contractsRemoved, + ], + [ + "**Changed or removed events.** Indexers and listeners filtering on the old signature " + + "stop receiving them:", + breaking.events, + ], + ].filter(([, items]) => items.length > 0); + const otherParts = [ + ["### New contracts", null, added.contracts], + ["### New functions", null, added.functions], + ["### New events", null, added.events], + [ + "### Errors", + "Custom errors change how a revert decodes, not whether a call succeeds:", + errors, + ], + ].filter(([, , items]) => items.length > 0); + const abiChanged = + Object.values(breaking).some((items) => items.length > 0) || otherParts.length > 0; + + const lines = []; + if (!args.previous && !args["previous-tag"]) { + lines.push("", "No earlier release carries ABIs to diff against."); + } else { lines.push("", `## ABI changes since ${previousTag}`, ""); - if (changedLines.length + otherLines.length === 0) { + if (breakingParts.length > 0) { + lines.push("### Breaking changes", ""); + for (const [intro, items] of breakingParts) lines.push(intro, ...items, ""); + } + if (!args.previous) { + lines.push( + declared.length > 0 + ? `${previousTag} has no ABIs to compare with, so only the breaking changes that ` + + "pull requests declared are listed." + : `${previousTag} has no ABIs to compare with.`, + ); + } else if (!abiChanged) { lines.push(`No ABI changes since ${previousTag}.`); - } else { - if (changedLines.length > 0) { - lines.push( - "**Changed signatures.** Existing callers of these get a bare revert until updated:", - ...changedLines, - "", - ); - } - lines.push(...otherLines); } + for (const [heading, intro, items] of otherParts) { + lines.push(heading, "", ...(intro ? [intro, ""] : []), ...items, ""); + } + while (lines.at(-1) === "") lines.pop(); } if (args.json) writeJson(resolve(process.cwd(), args.json), report); console.log(lines.join("\n")); } +// Runs `gh api` with a jq filter and parses the result. The filter keeps each response small. +// A full comparison, with its list of changed files, could be too big for execFileSync. +function ghApi(path, jq) { + let output; + try { + output = execFileSync("gh", ["api", path, "--jq", jq], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + const detail = (err.stderr || err.message || "").toString().trim().split("\n")[0]; + fail(`gh api ${path} failed: ${detail}`); + } + try { + return JSON.parse(output); + } catch { + fail(`gh api ${path} did not return JSON`); + } +} + +// Writes the pull requests merged between two refs to a JSON file, for `abidiff --pulls`. It +// lists every commit in `base...head`, then asks GitHub which merged pull request each commit +// came from. All network calls happen here, so `abidiff` only ever reads files. +// +// Every pull request is read, not only the ones labelled `breaking`. A breaking change that is +// left out of the release notes is costly for the people who integrate with us, and a label is +// easy to forget. The cost is one API call per commit. For a release of a few hundred commits +// that is quick and well within the API rate limit. If releases grow to thousands of commits, +// or this step gets slow, look for a cheaper approach, such as a pull request check that makes +// the `breaking` label and the description agree, and then read only the labelled ones. +// +// Any failed read stops the release. Notes that silently miss a breaking change are worse than +// a step that has to be re-run. +function pulls(args) { + const { repo, base, head, out } = args; + if (!repo || !base || !head || !out) fail("pulls needs --repo, --base, --head and --out"); + const range = `${encodeURIComponent(base)}...${encodeURIComponent(head)}`; + const shas = []; + let total = null; + for (let page = 1; total === null || shas.length < total; page += 1) { + const result = ghApi( + `repos/${repo}/compare/${range}?per_page=100&page=${page}`, + "{total: .total_commits, shas: [.commits[].sha]}", + ); + total = result.total; + if (result.shas.length === 0) break; + shas.push(...result.shas); + } + // If the list is short or has repeats, some pull requests would be missed without a warning. + if (shas.length !== total || new Set(shas).size !== total) { + fail(`${base}...${head} has ${total} commits but ${new Set(shas).size} could be listed`); + } + const merged = new Map(); + for (const sha of shas) { + const prs = ghApi( + `repos/${repo}/commits/${sha}/pulls`, + "[.[] | select(.merged_at != null) | " + + "{number, title, url: .html_url, labels: [.labels[].name], body}]", + ); + for (const pr of prs) merged.set(pr.number, pr); + } + const list = [...merged.values()].sort((a, b) => a.number - b.number); + writeJson(resolve(process.cwd(), out), list); + log(`${list.length} merged pull request(s) across ${shas.length} commit(s) since ${base}`); +} + // `--addresses false` omits deployments.json. A pre-release is cut to be deployed, so the // addresses on record still belong to the previous deployment of different code; shipping them // under this tag would break the promise that a release's addresses and ABIs came from the same @@ -782,6 +1003,7 @@ if (mode === "build") build(args); else if (mode === "validate") validate(); else if (mode === "changelog") changelog(args); else if (mode === "changedset") changedset(args); +else if (mode === "pulls") pulls(args); else if (mode === "abidiff") abidiff(args); else if (mode === "verify") verify(args); else { @@ -789,7 +1011,9 @@ else { "usage: release-metadata.mjs build --tag [--out ] | validate | " + "changelog --current [--previous ] [--previous-tag ] | " + "changedset --previous | " + - "abidiff --current [--previous ] [--previous-tag ] [--json ] | " + + "pulls --repo --base --head --out | " + + "abidiff --current [--previous ] [--previous-tag ] [--json ] " + + "[--pulls ] | " + "verify --network --rpc [--tag ]", ); } From 6dd0e5a0065731c0a0a397b2be31498d2697f694 Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Tue, 29 Sep 2026 15:26:51 +0530 Subject: [PATCH 3/9] fix: PR template to let the author know where the breakinh changes land Signed-off-by: GHkrishna --- .github/PULL_REQUEST_TEMPLATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 09d45cc8..d41a6d80 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -55,6 +55,9 @@ **Breaking changes:** + + ## How to test ```bash From 56c4d9d276068c8a45296d639e2312ec8f44ac8e Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Tue, 29 Sep 2026 15:27:23 +0530 Subject: [PATCH 4/9] fix: update release artifacts Signed-off-by: GHkrishna --- RELEASE_ARTIFACTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/RELEASE_ARTIFACTS.md b/RELEASE_ARTIFACTS.md index 724f28dd..a12e8f82 100644 --- a/RELEASE_ARTIFACTS.md +++ b/RELEASE_ARTIFACTS.md @@ -83,6 +83,10 @@ The release surface is decided in `.github/abi-contracts.txt` so a contract reac The machine-readable form of the "ABI changes since ..." section of the release body: per contract, the functions, events, and errors added, removed, or changed since the previous release, at selector level. A **changed signature** entry is the one to alert on: the name still exists but the selector moved (a struct parameter gained a field, say), so an un-updated caller gets a bare revert with no data. Contracts new to the release or no longer published are flagged as such. When no earlier release carries ABIs to diff against, the file says so instead of guessing. +`declaredBreaking` is present when the release read its pull requests. It lists every pull request merged since the previous release that declares a breaking change, with its `number`, `title`, `url`, and `notes`. `notes` is the text from the "Breaking Changes" block of its description. It is empty when the pull request is only marked as breaking, by a `!` in its title, the `breaking` label, or a ticked breaking-change box. These entries can describe behaviour changes that no ABI shows. + +The release body puts the most important changes first. **Breaking changes** come first: the ones pull requests declared, then changed function signatures, removed functions, contracts no longer published, and changed or removed events (an indexer that filters on the old signature stops receiving them). New contracts, new functions, and new events come next. Custom errors come last, because a changed error only changes how a revert is decoded, not whether a call works. + ## Stability Unless a major version bump occurs, the layout of both files will not change: no key is removed, renamed, or given a different type. New keys may be added, so parse permissively and ignore what you do not recognise. @@ -133,7 +137,7 @@ key that owns every contract in it. The environment holds: Creating a `v*` tag is itself restricted to the dotns team by the `release tags` ruleset, so a release takes two distinct human actions: cutting the tag, and approving the run it starts. -`deployments.json`, `release-manifest.json`, and `codehashes.json` are generated during the release by `scripts/js/release-metadata.mjs build`, from the committed deployment manifests and the build that just ran; `abi-diff.json` comes from `abidiff` against the previous release's published ABIs. Neither is committed: an address stored in two tracked files eventually disagrees with itself, so `deployments//.json` is the only tracked copy. +`deployments.json`, `release-manifest.json`, and `codehashes.json` are generated during the release by `scripts/js/release-metadata.mjs build`, from the committed deployment manifests and the build that just ran; `abi-diff.json` comes from `abidiff` against the previous release's published ABIs, plus the descriptions of the pull requests merged since it, which `release-metadata.mjs pulls` reads (so both publish workflows have `pull-requests: read`). Neither is committed: an address stored in two tracked files eventually disagrees with itself, so `deployments//.json` is the only tracked copy. That file holds exactly one address per contract, the current one. Each deploy overwrites the entries it produces, so it tracks only the latest deployment for a network and never a history of them; previous address sets exist only in this repository's git history. It also carries no implementation addresses behind the UUPS proxies, and no record of which commit was deployed. From 805852c12758a24d3ba99ea92e86c29404d16ae1 Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Tue, 29 Sep 2026 19:25:46 +0530 Subject: [PATCH 5/9] fix: rearrance and remove duplication for breaking changes Signed-off-by: GHkrishna --- .github/workflows/publish-prerelease.yml | 34 +-- .github/workflows/publish-release.yml | 34 +-- scripts/js/release-metadata.mjs | 369 +++++++---------------- 3 files changed, 131 insertions(+), 306 deletions(-) diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 43b94b8b..f4f73dad 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -16,9 +16,6 @@ on: permissions: contents: write - # The release notes list the breaking changes that pull requests declare, so the job has to - # read the pull requests merged since the previous release. - pull-requests: read # Two runs for the same version would race to attach assets to the same draft. On a # dispatch `github.ref_name` is the branch, so key on the requested version instead. @@ -271,30 +268,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - # The previous published release's ABIs come from its zip. Comparing with them lists - # every selector change in the body, breaking ones first. The most important one is a - # function that keeps its name but changes its signature, because an old caller then - # gets a bare revert. If the previous release has no zip, the body says there is - # nothing to compare with, and the release goes on. - # - # The pull requests merged since that release are read as well, so the breaking - # changes they describe (the "Breaking Changes" block of the pull request template) - # are listed first. If that read fails, this step fails. Notes that silently miss a - # breaking change are worse than a step that has to be re-run. + # The previous published release's ABIs come from its zip; diffing against them + # announces every selector change in the body, most importantly a same-name + # signature change, which an old caller experiences as a bare revert. A previous + # release without the zip degrades to "no diff" rather than failing the release. PREV=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName 2>/dev/null || true) - diff_args=() - if [ -n "$PREV" ]; then - bun scripts/js/release-metadata.mjs pulls --repo "$GITHUB_REPOSITORY" \ - --base "$PREV" --head "$GITHUB_SHA" --out "$RUNNER_TEMP/pulls.json" - diff_args=(--previous-tag "$PREV" --pulls "$RUNNER_TEMP/pulls.json") - if gh release download "$PREV" --repo "$GITHUB_REPOSITORY" \ - --pattern 'dotns-abis-*.zip' --dir prev-abis >/dev/null 2>&1; then - unzip -q -o prev-abis/dotns-abis-*.zip -d prev-abis - diff_args+=(--previous prev-abis/abis) - fi + prev_args=() + if [ -n "$PREV" ] && gh release download "$PREV" --repo "$GITHUB_REPOSITORY" \ + --pattern 'dotns-abis-*.zip' --dir prev-abis >/dev/null 2>&1; then + unzip -q -o prev-abis/dotns-abis-*.zip -d prev-abis + prev_args=(--previous prev-abis/abis --previous-tag "$PREV") fi bun scripts/js/release-metadata.mjs abidiff --current release/abis \ - "${diff_args[@]}" --json release/abi-diff.json >> release-body.md + "${prev_args[@]}" --json release/abi-diff.json >> release-body.md - name: Create draft pre-release with artifacts uses: softprops/action-gh-release@v2 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 96913647..88cbbcb4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,9 +16,6 @@ on: permissions: contents: write - # The release notes list the breaking changes that pull requests declare, so the job has to - # read the pull requests merged since the previous release. - pull-requests: read # Two runs for the same version would race to attach assets to the same draft. On a # dispatch `github.ref_name` is the branch, so key on the requested version instead. @@ -287,30 +284,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - # The previous published release's ABIs come from its zip. Comparing with them lists - # every selector change in the body, breaking ones first. The most important one is a - # function that keeps its name but changes its signature, because an old caller then - # gets a bare revert. If the previous release has no zip, the body says there is - # nothing to compare with, and the release goes on. - # - # The pull requests merged since that release are read as well, so the breaking - # changes they describe (the "Breaking Changes" block of the pull request template) - # are listed first. If that read fails, this step fails. Notes that silently miss a - # breaking change are worse than a step that has to be re-run. + # The previous published release's ABIs come from its zip; diffing against them + # announces every selector change in the body, most importantly a same-name + # signature change, which an old caller experiences as a bare revert. A previous + # release without the zip degrades to "no diff" rather than failing the release. PREV=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName 2>/dev/null || true) - diff_args=() - if [ -n "$PREV" ]; then - bun scripts/js/release-metadata.mjs pulls --repo "$GITHUB_REPOSITORY" \ - --base "$PREV" --head "$GITHUB_SHA" --out "$RUNNER_TEMP/pulls.json" - diff_args=(--previous-tag "$PREV" --pulls "$RUNNER_TEMP/pulls.json") - if gh release download "$PREV" --repo "$GITHUB_REPOSITORY" \ - --pattern 'dotns-abis-*.zip' --dir prev-abis >/dev/null 2>&1; then - unzip -q -o prev-abis/dotns-abis-*.zip -d prev-abis - diff_args+=(--previous prev-abis/abis) - fi + prev_args=() + if [ -n "$PREV" ] && gh release download "$PREV" --repo "$GITHUB_REPOSITORY" \ + --pattern 'dotns-abis-*.zip' --dir prev-abis >/dev/null 2>&1; then + unzip -q -o prev-abis/dotns-abis-*.zip -d prev-abis + prev_args=(--previous prev-abis/abis --previous-tag "$PREV") fi bun scripts/js/release-metadata.mjs abidiff --current release/abis \ - "${diff_args[@]}" --json release/abi-diff.json >> release-body.md + "${prev_args[@]}" --json release/abi-diff.json >> release-body.md - name: Create draft release with artifacts uses: softprops/action-gh-release@v2 diff --git a/scripts/js/release-metadata.mjs b/scripts/js/release-metadata.mjs index 879b5a6b..52a0fc79 100644 --- a/scripts/js/release-metadata.mjs +++ b/scripts/js/release-metadata.mjs @@ -5,20 +5,16 @@ // changelog --current [--previous ] release-note lines about address changes // [--previous-tag ] // changedset --previous contracts whose built code differs, one per line -// pulls --repo --base pull requests merged since , as JSON -// --head --out for `abidiff --pulls` -// abidiff --current [--previous ] ABI diff for the release body +// abidiff --current [--previous ] selector-level ABI diff for the release body // [--previous-tag ] [--json ] -// [--pulls ] // verify --network --rpc [--tag ] check a committed manifest against a chain // -// `build`, `pulls` and `abidiff` run in both publish workflows, and `changelog` in the release -// one. `validate` runs on pull requests, so a broken manifest fails there rather than at release -// time. `verify` stays out of the release path, which must work without reaching a chain; with -// `--tag` it also checks the chain's declared protocol version and code identity. `changedset` -// names exactly what a release changed, for upgrade tooling (which lives outside this -// repository) and for humans. No dependencies: `cast` does the chain reads and the hashing, and -// `gh` reads the pull requests. +// `build` and `changelog` run in both publish workflows; `validate` runs on pull requests, so a +// broken manifest fails there rather than at release time. `verify` stays out of the release +// path, which must work without reaching a chain; with `--tag` it also checks the chain's +// declared protocol version and code identity. `changedset` names exactly what a release +// changed, for upgrade tooling (which lives outside this repository) and for humans. No +// dependencies: `cast` does the chain reads and the hashing. import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; @@ -344,8 +340,8 @@ function readAbiDir(dir) { // Selector-level diff of one contract's ABI. A struct gaining a field is the case that // motivated this: same function name, different selector, an old caller gets a bare revert. So -// "changed" (same name, different signature set) is kept apart from plain additions and -// removals, and `abidiff` lists it first among the breaking changes. +// "changed" (same name, different signature set) is the highest-severity class and is reported +// before pure additions and removals. function diffAbi(previous, current) { const before = signaturesByKind(previous); const after = signaturesByKind(current); @@ -388,88 +384,35 @@ function diffAbi(previous, current) { return result; } -// The pull request template's "Breaking Changes" block has some fixed lines: two checkboxes and -// a bold "Breaking changes:" label. They are not part of what the author wrote, so they are -// removed before the text is used. -const BREAKING_LABEL_LINE = /^\s*\*\*Breaking changes:?\*\*\s*$/i; -const BREAKING_TEMPLATE_LINES = [ - /^\s*[-*]\s*\[[ xX]\]\s*No breaking changes\s*$/i, - /^\s*[-*]\s*\[[ xX]\]\s*Breaking changes documented below\s*$/i, - BREAKING_LABEL_LINE, +// Order of the lines in the release body. Breaking changes are the ones that stop an existing +// caller or indexer from working. The rest are grouped after them. +const BREAKING_GROUPS = [ + "functionChanged", + "functionRemoved", + "contractRemoved", + "eventChanged", + "eventRemoved", +]; +const OTHER_GROUPS = [ + "contractAdded", + "functionAdded", + "eventAdded", + "errorChanged", + "errorAdded", + "errorRemoved", ]; -// Returns the breaking change a pull request declares, or null if it declares none. +// Writes the ABI part of the release body, plus the full diff as JSON. // -// The text is read from the "Breaking Changes" heading (or the bold "Breaking changes:" label) -// down to the next heading, without the template's fixed lines. A pull request is also treated -// as breaking when any of these is true, even if it has no text: -// * its title uses the conventional-commit `!`, like `feat!:` or `feat(store)!:` -// * it has the `breaking` label -// * the "Breaking change" or "Breaking changes documented below" box is ticked -// Any one signal is enough. Missing a real breaking change in the release notes costs far more -// than listing one that turns out to be harmless, so no single signal is trusted to be the only -// one. A pull request with a signal but no text is still listed, with a note saying so. -function declaredBreaking(pr) { - const body = String(pr.body ?? "") - .replace(/\r\n?/g, "\n") - .replace(//g, ""); - const lines = body.split("\n"); - const isHeading = (line) => /^\s{0,3}#{1,6}\s/.test(line); - // The heading has to say exactly "Breaking change(s)", so "Non-breaking changes" is not it. - const isBreakingHeading = (line) => /^\s{0,3}#{1,6}\s+breaking changes?:?\s*$/i.test(line); - const start = lines.findIndex( - (line) => isBreakingHeading(line) || BREAKING_LABEL_LINE.test(line), - ); - let notes = ""; - if (start !== -1) { - const rest = lines.slice(start + 1); - const end = rest.findIndex(isHeading); - notes = (end === -1 ? rest : rest.slice(0, end)) - .filter((line) => !BREAKING_TEMPLATE_LINES.some((pattern) => pattern.test(line))) - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); - // Authors often write a placeholder instead of leaving the block empty. - if (/^(none|n\/?a|no|-+|no breaking changes)\.?$/i.test(notes)) notes = ""; - } - const ticked = (label) => new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${label}\\s*$`, "im").test(body); - const flagged = - /^[a-z]+(\([^)]*\))?!:/i.test(pr.title ?? "") || - (pr.labels ?? []).includes("breaking") || - ticked("Breaking change") || - ticked("Breaking changes documented below"); - if (!notes && !flagged) return null; - return { number: pr.number, title: pr.title, url: pr.url, notes }; -} - -function declaredBreakingChanges(path) { - const prs = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8")); - if (!Array.isArray(prs)) fail(`${path} is not a list of pull requests; write it with \`pulls\``); - return prs - .map(declaredBreaking) - .filter(Boolean) - .sort((a, b) => a.number - b.number); -} - -// One list item per pull request. Its text is indented so that any lists or paragraphs the -// author wrote stay inside that item. -function declaredLines({ number, title, url, notes }) { - const body = notes - ? notes.split("\n").map((line) => (line === "" ? "" : ` ${line}`)) - : [" Marked as breaking; the description gives no details."]; - return [`- [#${number}](${url}) ${title}`, ...body]; -} - -// Writes the "ABI changes" part of the release body, and the same data as JSON. The most -// important changes come first: -// 1. Breaking changes: what pull requests declared, then changed function signatures, -// removed functions, contracts no longer published, and changed or removed events. -// 2. New contracts, new functions and new events. -// 3. Custom errors. A changed error only changes how a revert is decoded, not whether a call -// works. -// "Breaking" means something that used to work stops working: a call now reverts, or an -// indexer stops receiving an event. Pull requests can also declare behaviour changes that no -// ABI shows, which is why their text is included. +// The body is kept short so that people read it. It lists only the breaking changes, one line +// each: changed function signatures, removed functions, contracts no longer published, then +// changed or removed events. Everything else (additions, new contracts, custom errors) goes in a +// collapsed block with a count. The JSON always has the full diff for every contract. +// +// A contract `Foo` and its interface `IFoo` usually publish the same functions and events, so +// one change would be listed twice. A line for `Foo` is left out when `IFoo` has exactly the same +// line, because callers bind to the interface. A change that only `Foo` has is still listed. +// This is the same `Foo` and `IFoo` pairing that `.github/abi-contracts.txt` uses. // // When there is nothing to report, it says so, so an empty section is never mistaken for a // missing one. A previous release without ABIs is reported the same way instead of failing. @@ -477,201 +420,114 @@ function abidiff(args) { if (!args.current) fail("abidiff needs --current "); const previousTag = args["previous-tag"] ?? "the previous release"; const report = { previousTag: args["previous-tag"] ?? null, contracts: {} }; - const declared = args.pulls ? declaredBreakingChanges(args.pulls) : []; - if (args.pulls) report.declaredBreaking = declared; - - const breaking = { - functionsChanged: [], - functionsRemoved: [], - contractsRemoved: [], - events: [], - }; - const added = { contracts: [], functions: [], events: [] }; - const errors = []; - const code = (text) => `\`${text}\``; - const signatureList = (list) => list.map(code).join(", ") || "(none)"; + const lines = [""]; if (!args.previous) { + lines.push("No earlier release carries ABIs to diff against."); report.previousUnavailable = true; } else { const currentAbis = readAbiDir(resolve(process.cwd(), args.current)); const previousAbis = readAbiDir(resolve(process.cwd(), args.previous)); + const code = (text) => `\`${text}\``; + const signatures = (list) => list.map(code).join(", "); + const member = (signature) => signature.slice(0, signature.indexOf("(")); + + // One entry per change. `change` describes the change without naming the contract, so a + // contract's entry can be matched against its interface's. + const entries = []; + const add = (contract, group, change, text) => entries.push({ contract, group, change, text }); + for (const name of [...currentAbis.keys()].sort()) { const previousAbi = previousAbis.get(name); if (!previousAbi) { - added.contracts.push(`- ${code(name)}`); + add(name, "contractAdded", "added", "new contract"); report.contracts[name] = { newContract: true }; continue; } const diff = diffAbi(previousAbi, currentAbis.get(name)); if (Object.keys(diff).length === 0) continue; report.contracts[name] = diff; - for (const [kind, entries] of Object.entries(diff)) { - const changedLines = entries.changed.map( - (entry) => - `- ${code(name)}: ${code(entry.name)} changed signature: ` + - `${signatureList(entry.was)} is now ${signatureList(entry.now)}`, - ); - const addedLines = entries.added.map((signature) => `- ${code(name)}: ${code(signature)}`); - const removedLines = entries.removed.map( - (signature) => `- ${code(name)}: ${code(signature)} removed`, - ); - if (kind === "function") { - breaking.functionsChanged.push(...changedLines); - // Listed under a "Removed functions" heading, so the line needs no "removed". - breaking.functionsRemoved.push( - ...entries.removed.map((signature) => `- ${code(name)}: ${code(signature)}`), + for (const [kind, { changed, added, removed }] of Object.entries(diff)) { + // Functions need no label. Events and errors say what they are. + const label = kind === "function" ? "" : ` ${kind}`; + for (const entry of changed) { + add( + name, + `${kind}Changed`, + `${kind} ${entry.was.join(" ")} > ${entry.now.join(" ")}`, + `${code(`${name}.${entry.name}`)}${label}: ` + + `${signatures(entry.was)} → ${signatures(entry.now)}`, ); - added.functions.push(...addedLines); - } else if (kind === "event") { - breaking.events.push(...changedLines, ...removedLines); - added.events.push(...addedLines); - } else { - errors.push( - ...changedLines, - ...entries.added.map((signature) => `- ${code(name)}: ${code(signature)} added`), - ...removedLines, + } + for (const signature of removed) { + add( + name, + `${kind}Removed`, + `${kind} removed ${signature}`, + `${code(`${name}.${member(signature)}`)}${label}: ${code(signature)} removed`, + ); + } + for (const signature of added) { + add( + name, + `${kind}Added`, + `${kind} added ${signature}`, + `${code(`${name}.${member(signature)}`)}${label}: ${code(signature)} added`, ); } } } for (const name of [...previousAbis.keys()].sort()) { if (!currentAbis.has(name)) { - breaking.contractsRemoved.push(`- ${code(name)}`); + add(name, "contractRemoved", "removed", "no longer published"); report.contracts[name] = { removedContract: true }; } } - } - const breakingParts = [ - [ - "**Declared in pull requests.** These can include behaviour changes an ABI does not show:", - declared.flatMap(declaredLines), - ], - [ - "**Changed function signatures.** Existing callers get a bare revert until they update:", - breaking.functionsChanged, - ], - ["**Removed functions.** Calls to these now revert:", breaking.functionsRemoved], - [ - "**Contracts no longer published.** Their ABIs are not in this release:", - breaking.contractsRemoved, - ], - [ - "**Changed or removed events.** Indexers and listeners filtering on the old signature " + - "stop receiving them:", - breaking.events, - ], - ].filter(([, items]) => items.length > 0); - const otherParts = [ - ["### New contracts", null, added.contracts], - ["### New functions", null, added.functions], - ["### New events", null, added.events], - [ - "### Errors", - "Custom errors change how a revert decodes, not whether a call succeeds:", - errors, - ], - ].filter(([, , items]) => items.length > 0); - const abiChanged = - Object.values(breaking).some((items) => items.length > 0) || otherParts.length > 0; - - const lines = []; - if (!args.previous && !args["previous-tag"]) { - lines.push("", "No earlier release carries ABIs to diff against."); - } else { - lines.push("", `## ABI changes since ${previousTag}`, ""); - if (breakingParts.length > 0) { - lines.push("### Breaking changes", ""); - for (const [intro, items] of breakingParts) lines.push(intro, ...items, ""); - } - if (!args.previous) { - lines.push( - declared.length > 0 - ? `${previousTag} has no ABIs to compare with, so only the breaking changes that ` + - "pull requests declared are listed." - : `${previousTag} has no ABIs to compare with.`, - ); - } else if (!abiChanged) { - lines.push(`No ABI changes since ${previousTag}.`); - } - for (const [heading, intro, items] of otherParts) { - lines.push(heading, "", ...(intro ? [intro, ""] : []), ...items, ""); + // Leave out a contract's line when its interface has the same one. A whole contract that + // was added or removed together with its interface becomes one line naming both. + const byChange = new Map(entries.map((entry) => [`${entry.contract} ${entry.change}`, entry])); + const shown = entries.filter((entry) => { + const partner = byChange.get(`I${entry.contract} ${entry.change}`); + if (!partner) return true; + if (entry.group === "contractAdded" || entry.group === "contractRemoved") { + partner.names = [partner.contract, entry.contract]; + } + return false; + }); + const line = (entry) => + entry.group === "contractAdded" || entry.group === "contractRemoved" + ? `- ${(entry.names ?? [entry.contract]).map(code).join(" and ")}: ` + + (entry.names && entry.group === "contractAdded" ? "new contracts" : entry.text) + : `- ${entry.text}`; + const inOrder = (groups) => + groups.flatMap((group) => shown.filter((entry) => entry.group === group)).map(line); + const breaking = inOrder(BREAKING_GROUPS); + const others = inOrder(OTHER_GROUPS); + + if (breaking.length + others.length === 0) { + lines.push(`## ABI changes since ${previousTag}`, "", `No ABI changes since ${previousTag}.`); + } else { + lines.push(`## Breaking ABI changes since ${previousTag}`, ""); + lines.push(...(breaking.length > 0 ? breaking : ["None."])); + if (others.length > 0) { + lines.push( + "", + "
", + `Other ABI changes (${others.length})`, + "", + ...others, + "", + "
", + ); + } } - while (lines.at(-1) === "") lines.pop(); } if (args.json) writeJson(resolve(process.cwd(), args.json), report); console.log(lines.join("\n")); } -// Runs `gh api` with a jq filter and parses the result. The filter keeps each response small. -// A full comparison, with its list of changed files, could be too big for execFileSync. -function ghApi(path, jq) { - let output; - try { - output = execFileSync("gh", ["api", path, "--jq", jq], { - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (err) { - const detail = (err.stderr || err.message || "").toString().trim().split("\n")[0]; - fail(`gh api ${path} failed: ${detail}`); - } - try { - return JSON.parse(output); - } catch { - fail(`gh api ${path} did not return JSON`); - } -} - -// Writes the pull requests merged between two refs to a JSON file, for `abidiff --pulls`. It -// lists every commit in `base...head`, then asks GitHub which merged pull request each commit -// came from. All network calls happen here, so `abidiff` only ever reads files. -// -// Every pull request is read, not only the ones labelled `breaking`. A breaking change that is -// left out of the release notes is costly for the people who integrate with us, and a label is -// easy to forget. The cost is one API call per commit. For a release of a few hundred commits -// that is quick and well within the API rate limit. If releases grow to thousands of commits, -// or this step gets slow, look for a cheaper approach, such as a pull request check that makes -// the `breaking` label and the description agree, and then read only the labelled ones. -// -// Any failed read stops the release. Notes that silently miss a breaking change are worse than -// a step that has to be re-run. -function pulls(args) { - const { repo, base, head, out } = args; - if (!repo || !base || !head || !out) fail("pulls needs --repo, --base, --head and --out"); - const range = `${encodeURIComponent(base)}...${encodeURIComponent(head)}`; - const shas = []; - let total = null; - for (let page = 1; total === null || shas.length < total; page += 1) { - const result = ghApi( - `repos/${repo}/compare/${range}?per_page=100&page=${page}`, - "{total: .total_commits, shas: [.commits[].sha]}", - ); - total = result.total; - if (result.shas.length === 0) break; - shas.push(...result.shas); - } - // If the list is short or has repeats, some pull requests would be missed without a warning. - if (shas.length !== total || new Set(shas).size !== total) { - fail(`${base}...${head} has ${total} commits but ${new Set(shas).size} could be listed`); - } - const merged = new Map(); - for (const sha of shas) { - const prs = ghApi( - `repos/${repo}/commits/${sha}/pulls`, - "[.[] | select(.merged_at != null) | " + - "{number, title, url: .html_url, labels: [.labels[].name], body}]", - ); - for (const pr of prs) merged.set(pr.number, pr); - } - const list = [...merged.values()].sort((a, b) => a.number - b.number); - writeJson(resolve(process.cwd(), out), list); - log(`${list.length} merged pull request(s) across ${shas.length} commit(s) since ${base}`); -} - // `--addresses false` omits deployments.json. A pre-release is cut to be deployed, so the // addresses on record still belong to the previous deployment of different code; shipping them // under this tag would break the promise that a release's addresses and ABIs came from the same @@ -1003,7 +859,6 @@ if (mode === "build") build(args); else if (mode === "validate") validate(); else if (mode === "changelog") changelog(args); else if (mode === "changedset") changedset(args); -else if (mode === "pulls") pulls(args); else if (mode === "abidiff") abidiff(args); else if (mode === "verify") verify(args); else { @@ -1011,9 +866,7 @@ else { "usage: release-metadata.mjs build --tag [--out ] | validate | " + "changelog --current [--previous ] [--previous-tag ] | " + "changedset --previous | " + - "pulls --repo --base --head --out | " + - "abidiff --current [--previous ] [--previous-tag ] [--json ] " + - "[--pulls ] | " + + "abidiff --current [--previous ] [--previous-tag ] [--json ] | " + "verify --network --rpc [--tag ]", ); } From bfd7dc67a79a08ac115116ab60bcea8288fbdfc5 Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Tue, 29 Sep 2026 19:26:11 +0530 Subject: [PATCH 6/9] fix: update artifacts Signed-off-by: GHkrishna --- RELEASE_ARTIFACTS.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/RELEASE_ARTIFACTS.md b/RELEASE_ARTIFACTS.md index a12e8f82..7d110917 100644 --- a/RELEASE_ARTIFACTS.md +++ b/RELEASE_ARTIFACTS.md @@ -81,11 +81,9 @@ The release surface is decided in `.github/abi-contracts.txt` so a contract reac ## `abi-diff.json` -The machine-readable form of the "ABI changes since ..." section of the release body: per contract, the functions, events, and errors added, removed, or changed since the previous release, at selector level. A **changed signature** entry is the one to alert on: the name still exists but the selector moved (a struct parameter gained a field, say), so an un-updated caller gets a bare revert with no data. Contracts new to the release or no longer published are flagged as such. When no earlier release carries ABIs to diff against, the file says so instead of guessing. +The full, machine-readable ABI diff behind the release body: per contract, the functions, events, and errors added, removed, or changed since the previous release, at selector level. A **changed signature** entry is the one to alert on: the name still exists but the selector moved (a struct parameter gained a field, say), so an un-updated caller gets a bare revert with no data. Contracts new to the release or no longer published are flagged as such. When no earlier release carries ABIs to diff against, the file says so instead of guessing. -`declaredBreaking` is present when the release read its pull requests. It lists every pull request merged since the previous release that declares a breaking change, with its `number`, `title`, `url`, and `notes`. `notes` is the text from the "Breaking Changes" block of its description. It is empty when the pull request is only marked as breaking, by a `!` in its title, the `breaking` label, or a ticked breaking-change box. These entries can describe behaviour changes that no ABI shows. - -The release body puts the most important changes first. **Breaking changes** come first: the ones pull requests declared, then changed function signatures, removed functions, contracts no longer published, and changed or removed events (an indexer that filters on the old signature stops receiving them). New contracts, new functions, and new events come next. Custom errors come last, because a changed error only changes how a revert is decoded, not whether a call works. +The release body is a short summary of this file. Under "Breaking ABI changes since ..." it lists only the changes that break an existing caller or indexer, one line each: changed function signatures, removed functions, contracts no longer published, then changed or removed events. Additions, new contracts, and custom errors are in a collapsed block with a count. A contract `Foo` and its interface `IFoo` usually carry the same change, so the body lists it once, under `IFoo`, which is what callers bind to. A change that only `Foo` has is still listed. This file always has every contract, both halves of a pair included. ## Stability @@ -137,7 +135,7 @@ key that owns every contract in it. The environment holds: Creating a `v*` tag is itself restricted to the dotns team by the `release tags` ruleset, so a release takes two distinct human actions: cutting the tag, and approving the run it starts. -`deployments.json`, `release-manifest.json`, and `codehashes.json` are generated during the release by `scripts/js/release-metadata.mjs build`, from the committed deployment manifests and the build that just ran; `abi-diff.json` comes from `abidiff` against the previous release's published ABIs, plus the descriptions of the pull requests merged since it, which `release-metadata.mjs pulls` reads (so both publish workflows have `pull-requests: read`). Neither is committed: an address stored in two tracked files eventually disagrees with itself, so `deployments//.json` is the only tracked copy. +`deployments.json`, `release-manifest.json`, and `codehashes.json` are generated during the release by `scripts/js/release-metadata.mjs build`, from the committed deployment manifests and the build that just ran; `abi-diff.json` comes from `abidiff` against the previous release's published ABIs. Neither is committed: an address stored in two tracked files eventually disagrees with itself, so `deployments//.json` is the only tracked copy. That file holds exactly one address per contract, the current one. Each deploy overwrites the entries it produces, so it tracks only the latest deployment for a network and never a history of them; previous address sets exist only in this repository's git history. It also carries no implementation addresses behind the UUPS proxies, and no record of which commit was deployed. From d4103f90af5f4d73b1cbd15df065f96c9d5f866c Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Wed, 30 Sep 2026 11:42:47 +0530 Subject: [PATCH 7/9] fix: avoid copying PR bodies in notes Signed-off-by: GHkrishna --- .github/PULL_REQUEST_TEMPLATE.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d41a6d80..09d45cc8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -55,9 +55,6 @@ **Breaking changes:** - - ## How to test ```bash From 3a391c1dc96f513861db9f7aadfb789d92835190 Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Wed, 30 Sep 2026 11:44:56 +0530 Subject: [PATCH 8/9] fix: avoid duplication of interfaces and implementation changes in breaking changes Signed-off-by: GHkrishna --- .github/workflows/publish-prerelease.yml | 8 +- .github/workflows/publish-release.yml | 8 +- scripts/js/release-metadata.mjs | 134 ++++++++++++++++++++--- 3 files changed, 133 insertions(+), 17 deletions(-) diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index f4f73dad..6e40e24e 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -186,6 +186,11 @@ jobs: find release/abis -maxdepth 1 -type f -printf '%f\n' | sort > release/expected-assets.txt echo "Extracted $(wc -l < release/expected-assets.txt) ABIs" + # Record which published ABIs each contract inherits from, read from the same build. + # The ABI diff step uses it to list a change once, under the interface that declares + # it, even when the names do not match. Kept out of release/, since it is not an asset. + bun scripts/js/release-metadata.mjs bases --out "$RUNNER_TEMP/abi-bases.json" + # No addresses: a pre-release is cut to be deployed, so the recorded addresses still # belong to the previous deployment of different code. Publishing them under this tag # would break the promise that a release's addresses and ABIs come from the same release. @@ -280,7 +285,8 @@ jobs: prev_args=(--previous prev-abis/abis --previous-tag "$PREV") fi bun scripts/js/release-metadata.mjs abidiff --current release/abis \ - "${prev_args[@]}" --json release/abi-diff.json >> release-body.md + "${prev_args[@]}" --bases "$RUNNER_TEMP/abi-bases.json" \ + --json release/abi-diff.json >> release-body.md - name: Create draft pre-release with artifacts uses: softprops/action-gh-release@v2 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 88cbbcb4..4f73e3c2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -182,6 +182,11 @@ jobs: find release/abis -maxdepth 1 -type f -printf '%f\n' | sort > release/expected-assets.txt echo "Extracted $(wc -l < release/expected-assets.txt) ABIs" + # Record which published ABIs each contract inherits from, read from the same build. + # The ABI diff step uses it to list a change once, under the interface that declares + # it, even when the names do not match. Kept out of release/, since it is not an asset. + bun scripts/js/release-metadata.mjs bases --out "$RUNNER_TEMP/abi-bases.json" + # Addresses come from the committed deployment manifests, the same files the # deployment pipeline in dotns-releases measures a live deploy against. A consumer # that only has the release needs them to reach any contract at all. @@ -296,7 +301,8 @@ jobs: prev_args=(--previous prev-abis/abis --previous-tag "$PREV") fi bun scripts/js/release-metadata.mjs abidiff --current release/abis \ - "${prev_args[@]}" --json release/abi-diff.json >> release-body.md + "${prev_args[@]}" --bases "$RUNNER_TEMP/abi-bases.json" \ + --json release/abi-diff.json >> release-body.md - name: Create draft release with artifacts uses: softprops/action-gh-release@v2 diff --git a/scripts/js/release-metadata.mjs b/scripts/js/release-metadata.mjs index 52a0fc79..acaf49fb 100644 --- a/scripts/js/release-metadata.mjs +++ b/scripts/js/release-metadata.mjs @@ -5,12 +5,15 @@ // changelog --current [--previous ] release-note lines about address changes // [--previous-tag ] // changedset --previous contracts whose built code differs, one per line +// bases --out published ABIs each contract inherits from // abidiff --current [--previous ] selector-level ABI diff for the release body // [--previous-tag ] [--json ] +// [--bases ] // verify --network --rpc [--tag ] check a committed manifest against a chain // -// `build` and `changelog` run in both publish workflows; `validate` runs on pull requests, so a -// broken manifest fails there rather than at release time. `verify` stays out of the release +// `build`, `bases` and `abidiff` run in both publish workflows, and `changelog` in the release +// one. `validate` runs on pull requests, so a broken manifest fails there rather than at release +// time. `verify` stays out of the release // path, which must work without reaching a chain; with `--tag` it also checks the chain's // declared protocol version and code identity. `changedset` names exactly what a release // changed, for upgrade tooling (which lives outside this repository) and for humans. No @@ -384,6 +387,87 @@ function diffAbi(previous, current) { return result; } +// For each published ABI, the other published ABIs it inherits from, nearest first. This comes +// from the build itself, not from names, so a contract is matched with its interface whatever +// they are called. For example, DotnsFlatPricing implements IDotnsPricing. Bases that are not +// published ABIs are skipped, because the release body can only point at ABIs that ship in the +// release. +// +// A contract's artifact lists its bases as AST ids. Ids only mean something inside the compiler +// run that made them, and an incremental `forge build` can leave artifacts from several runs in +// out/. So each artifact is matched to the build info of its own run (foundry.toml keeps +// `build_info = true`), and its base ids are turned into names there. +function publishedBases() { + const buildInfoDir = join(ROOT, "out", "build-info"); + if (!existsSync(buildInfoDir)) { + fail(`${buildInfoDir} not found; foundry.toml needs \`build_info = true\`, then forge build`); + } + // For each compiler run: contract id -> { name, path }. + const runs = readdirSync(buildInfoDir) + .filter((file) => file.endsWith(".json")) + .map((file) => { + const info = JSON.parse(readFileSync(join(buildInfoDir, file), "utf8")); + const byId = new Map(); + for (const [path, source] of Object.entries(info?.output?.sources ?? {})) { + for (const node of source?.ast?.nodes ?? []) { + if (node.nodeType === "ContractDefinition") byId.set(node.id, { name: node.name, path }); + } + } + return byId; + }); + + const published = readContractNames(); + const publishedSet = new Set(published); + const bases = {}; + for (const name of published) { + const path = join(ROOT, "out", `${name}.sol`, `${name}.json`); + if (!existsSync(path)) { + fail(`${path} not found; run forge build, or check .github/abi-contracts.txt`); + } + const artefact = JSON.parse(readFileSync(path, "utf8")); + const def = artefact?.ast?.nodes?.find( + (node) => node.nodeType === "ContractDefinition" && node.name === name, + ); + if (!def) fail(`${path} has no AST for ${name}; foundry.toml needs \`ast = true\``); + // The run that built this artifact is the one that has this contract, in this file, under + // the same id. + const run = runs.find((byId) => { + const known = byId.get(def.id); + return known?.name === name && known.path === artefact.ast.absolutePath; + }); + if (!run) { + fail(`no build info in out/build-info matches ${name}; run forge clean && forge build`); + } + // The first id is the contract itself. + const found = (def.linearizedBaseContracts ?? []) + .slice(1) + .map((id) => run.get(id)?.name) + .filter((base) => base && publishedSet.has(base)); + if (found.length > 0) bases[name] = found; + } + return bases; +} + +function writeBases(args) { + if (!args.out) fail("bases needs --out "); + const bases = publishedBases(); + writeJson(resolve(process.cwd(), args.out), bases); + log(`${Object.keys(bases).length} published ABI(s) inherit from another published ABI`); +} + +function readBases(path) { + const bases = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8")); + const valid = + bases && + typeof bases === "object" && + !Array.isArray(bases) && + Object.values(bases).every((list) => Array.isArray(list)); + if (!valid) { + fail(`${path} does not map contract names to lists of bases; write it with \`bases\``); + } + return bases; +} + // Order of the lines in the release body. Breaking changes are the ones that stop an existing // caller or indexer from working. The rest are grouped after them. const BREAKING_GROUPS = [ @@ -409,10 +493,12 @@ const OTHER_GROUPS = [ // changed or removed events. Everything else (additions, new contracts, custom errors) goes in a // collapsed block with a count. The JSON always has the full diff for every contract. // -// A contract `Foo` and its interface `IFoo` usually publish the same functions and events, so -// one change would be listed twice. A line for `Foo` is left out when `IFoo` has exactly the same -// line, because callers bind to the interface. A change that only `Foo` has is still listed. -// This is the same `Foo` and `IFoo` pairing that `.github/abi-contracts.txt` uses. +// A contract and the interfaces it implements usually publish the same functions and events, so +// one change would be listed several times. `--bases` (written by `bases`) says which published +// ABIs each contract inherits from. A contract's line is left out when one of those bases has +// exactly the same line, so the change is listed once, under the interface that declares it, +// which is what callers bind to. A change that only the contract has is still listed. Without +// `--bases`, every contract is listed on its own. // // When there is nothing to report, it says so, so an empty section is never mistaken for a // missing one. A previous release without ABIs is reported the same way instead of failing. @@ -484,20 +570,35 @@ function abidiff(args) { } } - // Leave out a contract's line when its interface has the same one. A whole contract that - // was added or removed together with its interface becomes one line naming both. + // Leave out a contract's line when one of its bases has the same one. A contract that is new + // together with its bases is named on its base's line instead. That is the furthest base + // that is also new, which has no new base of its own, so the whole family ends up on one + // line. A removed contract is not in the current build, so its bases are not known and it + // keeps its own line. + const bases = args.bases ? readBases(args.bases) : {}; const byChange = new Map(entries.map((entry) => [`${entry.contract} ${entry.change}`, entry])); + const isContractLine = (entry) => + entry.group === "contractAdded" || entry.group === "contractRemoved"; const shown = entries.filter((entry) => { - const partner = byChange.get(`I${entry.contract} ${entry.change}`); - if (!partner) return true; - if (entry.group === "contractAdded" || entry.group === "contractRemoved") { - partner.names = [partner.contract, entry.contract]; + const covering = (bases[entry.contract] ?? []).filter((base) => + byChange.has(`${base} ${entry.change}`), + ); + if (covering.length === 0) return true; + if (isContractLine(entry)) { + const home = byChange.get(`${covering.at(-1)} ${entry.change}`); + home.names = [...(home.names ?? [home.contract]), entry.contract]; } return false; }); + const nameList = (names) => { + const quoted = names.map(code); + return quoted.length > 1 + ? `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}` + : quoted[0]; + }; const line = (entry) => - entry.group === "contractAdded" || entry.group === "contractRemoved" - ? `- ${(entry.names ?? [entry.contract]).map(code).join(" and ")}: ` + + isContractLine(entry) + ? `- ${nameList(entry.names ?? [entry.contract])}: ` + (entry.names && entry.group === "contractAdded" ? "new contracts" : entry.text) : `- ${entry.text}`; const inOrder = (groups) => @@ -859,6 +960,7 @@ if (mode === "build") build(args); else if (mode === "validate") validate(); else if (mode === "changelog") changelog(args); else if (mode === "changedset") changedset(args); +else if (mode === "bases") writeBases(args); else if (mode === "abidiff") abidiff(args); else if (mode === "verify") verify(args); else { @@ -866,7 +968,9 @@ else { "usage: release-metadata.mjs build --tag [--out ] | validate | " + "changelog --current [--previous ] [--previous-tag ] | " + "changedset --previous | " + - "abidiff --current [--previous ] [--previous-tag ] [--json ] | " + + "bases --out | " + + "abidiff --current [--previous ] [--previous-tag ] [--json ] " + + "[--bases ] | " + "verify --network --rpc [--tag ]", ); } From 56e3f960d386cbbccfab133ef5ac7fd90bb6b74f Mon Sep 17 00:00:00 2001 From: GHkrishna Date: Wed, 30 Sep 2026 11:45:33 +0530 Subject: [PATCH 9/9] fix: artifacts update for duplicated interfaces and implementations in breaking changes Signed-off-by: GHkrishna --- RELEASE_ARTIFACTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_ARTIFACTS.md b/RELEASE_ARTIFACTS.md index 7d110917..d46e3cf8 100644 --- a/RELEASE_ARTIFACTS.md +++ b/RELEASE_ARTIFACTS.md @@ -83,7 +83,7 @@ The release surface is decided in `.github/abi-contracts.txt` so a contract reac The full, machine-readable ABI diff behind the release body: per contract, the functions, events, and errors added, removed, or changed since the previous release, at selector level. A **changed signature** entry is the one to alert on: the name still exists but the selector moved (a struct parameter gained a field, say), so an un-updated caller gets a bare revert with no data. Contracts new to the release or no longer published are flagged as such. When no earlier release carries ABIs to diff against, the file says so instead of guessing. -The release body is a short summary of this file. Under "Breaking ABI changes since ..." it lists only the changes that break an existing caller or indexer, one line each: changed function signatures, removed functions, contracts no longer published, then changed or removed events. Additions, new contracts, and custom errors are in a collapsed block with a count. A contract `Foo` and its interface `IFoo` usually carry the same change, so the body lists it once, under `IFoo`, which is what callers bind to. A change that only `Foo` has is still listed. This file always has every contract, both halves of a pair included. +The release body is a short summary of this file. Under "Breaking ABI changes since ..." it lists only the changes that break an existing caller or indexer, one line each: changed function signatures, removed functions, contracts no longer published, then changed or removed events. Additions, new contracts, and custom errors are in a collapsed block with a count. A contract and the interfaces it implements usually carry the same change, so the body lists it once, under the interface that declares it, which is what callers bind to. Which published ABIs a contract inherits from is read from the build, not guessed from names, so `DotnsFlatPricing` is matched with `IDotnsPricing` too. A change that only the contract has is still listed. This file always has every contract, both the contract and its interfaces included. ## Stability