Skip to content
Open
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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package-lock.json

# Auto-generated files
packages/format/src/schemas/yamls.ts
packages/format/src/version.ts
packages/bugc/src/examples/generated.ts

# Solidity fixtures are compiler inputs; this repo has no Solidity Prettier parser.
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ The sections do not signal obligations; the prefixes do.

## Unreleased

### Added

- An `ethdebug` field on **ethdebug/format/info**,
**ethdebug/format/info/resources** and **ethdebug/format/program** names
the schema the object conforms to and the specification version that
defines it, through the new **ethdebug/format/identification** schema.
The field is optional now and becomes required at `0.1.0`. An object
without it predates the field ([#305]).
- Schemas: **ethdebug/format/identification**, **ethdebug/format/info**,
**ethdebug/format/info/resources**, **ethdebug/format/program**
- Producers: optional: emit `ethdebug: { schema, version }` with the
`@ethdebug/format` version the producer was built against; a program
inside a container may omit it, and when both carry it the versions
must be equal.
- Consumers: optional: read the field to learn which changelog entries
apply; reject an object only when its compatibility key (the major
version, or `major.minor` while the major is 0) differs from the
supported one, and warn when the version is newer.
**ethdebug/format/program** and **ethdebug/format/info** are closed
objects (`unevaluatedProperties: false`), so a consumer that
validates against the previous release's schemas rejects an
identified object until it updates its schemas.

## 0.1.0-draft.0 — 2026-09-21

The version scheme changed: prerelease versions of the specification are now
Expand Down Expand Up @@ -616,3 +639,4 @@ First published version of the specification.
[#285]: https://github.com/ethdebug/format/pull/285
[#286]: https://github.com/ethdebug/format/pull/286
[#303]: https://github.com/ethdebug/format/pull/303
[#305]: https://github.com/ethdebug/format/pull/305
19 changes: 19 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ guards that run in CI live in `bin/check-tarballs.ts` and
workspace that depends on a moving one, directly or transitively.
Every workspace depends on `@ethdebug/format`, so a specification
change moves all ten.
- When `@ethdebug/format` moves, the `Publish` commit also rewrites
the specification version in the schema examples, so `schemas/` may
appear in that commit beside the manifests; the dry run prints the
count of version literals it rewrites.
- Series convention, for now: all workspaces start a `major.minor`
series together and graduate together with the spec; between those
events each workspace moves only when it or a dependency changed,
Expand Down Expand Up @@ -130,6 +134,21 @@ guards that run in CI live in `bin/check-tarballs.ts` and
yarn tsx bin/version.ts [keyword] [--all]
```

When `@ethdebug/format` moves, the `Publish` commit also rewrites
the specification version in the schema examples, so `schemas/` may
appear in that commit beside the manifests; the dry run prints the
count of version literals it rewrites.

A `found N version literals, expected M` plan problem means a
schema example gained or lost an `ethdebug` block without its
`version:` line, or lost the line while keeping the block; edit
the example and re-run.

After the bump, run `yarn build` before running
`yarn test packages/format` again: the generated
`src/version.ts` still names the old version until the build
regenerates it. CI does this step in the publish workflow.

The script never pushes. If it fails after it started writing, it
prints the undo commands for the stage it reached. The dry run of
step 3 reports the same guards and findings as this run, but it
Expand Down
105 changes: 104 additions & 1 deletion bin/version.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
changelogProblems,
expectedVersionSites,
forcedNames,
hasReleaseSection,
hasUnreleasedEntries,
Expand All @@ -15,6 +16,7 @@ import {
planProblems,
requiredChangelogs,
rewriteManifest,
rewriteSchemaVersions,
undoAdvice,
} from "./version.js";

Expand Down Expand Up @@ -608,7 +610,7 @@ describe("undoAdvice", () => {

it("restores the manifests when nothing was committed or tagged", () => {
expect(undoAdvice([], false)).toBe(
"undo: git checkout HEAD -- packages/*/package.json",
"undo: git checkout HEAD -- packages/*/package.json schemas/",
);
});
});
Expand Down Expand Up @@ -647,3 +649,104 @@ describe("a plan of first releases only", () => {
expect(rewriteManifest(text, versions)).toBe(text);
});
});

describe("rewriteSchemaVersions", () => {
const text = [
"examples:",
" - ethdebug:",
' schema: "schema:ethdebug/format/program"',
' version: "0.1.0-draft.0"',
" compilation:",
" compiler:",
" version: 0.2.3+commit.8b37fa7a",
' # version: "0.1.0-draft.0" in a comment stays',
"",
].join("\n");

it("rewrites the quoted literal and leaves compiler versions alone", () => {
const result = rewriteSchemaVersions(
text,
"0.1.0-draft.0",
"0.1.0-draft.1",
);
expect(result.count).toBe(1);
expect(result.text).toContain('version: "0.1.0-draft.1"');
expect(result.text).toContain("version: 0.2.3+commit.8b37fa7a");
expect(result.text).toContain('# version: "0.1.0-draft.0" in a comment');
});

it("accepts single quotes and no quotes, keeping the style", () => {
expect(
rewriteSchemaVersions(
"version: '0.1.0-draft.0'\n",
"0.1.0-draft.0",
"0.2.0",
).text,
).toBe("version: '0.2.0'\n");
expect(
rewriteSchemaVersions(
" version: 0.1.0-draft.0\n",
"0.1.0-draft.0",
"0.2.0",
).text,
).toBe(" version: 0.2.0\n");
});

it("does not match a prefix of a longer version", () => {
expect(
rewriteSchemaVersions('version: "0.1.0-draft.10"\n', "0.1.0-draft.1", "x")
.count,
).toBe(0);
});

it("keeps a trailing comment on the line it rewrites", () => {
const result = rewriteSchemaVersions(
' version: "0.1.0-draft.0" # the spec version\n',
"0.1.0-draft.0",
"0.1.0-draft.1",
);
expect(result.count).toBe(1);
expect(result.text).toBe(
' version: "0.1.0-draft.1" # the spec version\n',
);
});

it("leaves the version mentioned in prose alone", () => {
const prose = " description: Written by 0.1.0-draft.0 producers.\n";
expect(rewriteSchemaVersions(prose, "0.1.0-draft.0", "0.2.0")).toEqual({
text: prose,
count: 0,
});
});
});

describe("expectedVersionSites", () => {
it("counts ethdebug blocks and the identification example", () => {
const withBlocks =
'examples:\n - ethdebug:\n schema: x\n version: "1"\n' +
' - foo:\n ethdebug:\n version: "1"\n';
expect(expectedVersionSites(withBlocks)).toBe(2);
const identification =
'$id: "schema:ethdebug/format/identification"\n' +
'examples:\n - schema: x\n version: "1"\n';
expect(expectedVersionSites(identification)).toBe(1);
});

// the property that declares the field is not an example of it
it("ignores the ethdebug property of a root schema", () => {
const root =
'$id: "schema:ethdebug/format/program"\n' +
"properties:\n ethdebug:\n allOf:\n - $ref: x\n" +
'examples:\n - ethdebug:\n version: "1"\n';
expect(expectedVersionSites(root)).toBe(1);
});

// what the count check compares: a literal left behind falls short
it("exceeds the count when an example keeps the old version", () => {
const stale =
'examples:\n - ethdebug:\n version: "0.1.0-draft.0"\n' +
' - ethdebug:\n version: "0.1.0-draft.0"\n';
const { count } = rewriteSchemaVersions(stale, "0.1.0-draft.1", "x");
expect(count).toBeLessThan(expectedVersionSites(stale));
});
});
117 changes: 115 additions & 2 deletions bin/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,44 @@ export function rewriteManifest(
return `${JSON.stringify(json, null, 2)}\n`;
}

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

// the version literal a schema example carries, whatever its quoting;
// a comment or a longer version is not a match
export function rewriteSchemaVersions(
text: string,
oldVersion: string,
newVersion: string,
): { text: string; count: number } {
const pattern = new RegExp(
`^(\\s*version:\\s*)(["']?)${escapeRegExp(oldVersion)}\\2(\\s*(?:#.*)?)$`,
"gm",
);
let count = 0;
const rewritten = text.replace(pattern, (_, head, quote, tail) => {
count += 1;
return `${head}${quote}${newVersion}${quote}${tail}`;
});
return { text: rewritten, count };
}

// how many version literals a schema file is expected to carry: one per
// `ethdebug:` block under its top-level `examples:`, plus one for the
// identification schema, whose own example carries the literal directly.
// The `ethdebug:` property that declares the field sits above
// `examples:` and does not count
export function expectedVersionSites(text: string): number {
const examples = text.search(/^examples:[ \t\r]*$/m);
const region = examples === -1 ? "" : text.slice(examples);
const blocks = region.match(/^\s*(?:- )?ethdebug:\s*$/gm)?.length ?? 0;
const own = /^\$id: "schema:ethdebug\/format\/identification"$/m.test(text)
? 1
: 0;
return blocks + own;
}

function git(root: string, args: string[]): string {
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
}
Expand Down Expand Up @@ -603,6 +641,53 @@ function writeManifests(
return written;
}

// the schema files whose examples name the specification version, each
// with the text it gets when @ethdebug/format moves. `count` is what
// the rewrite found and `expected` what the files ask for; the two must
// agree, or an example no longer carries the version the release names
interface SchemaRewrites {
files: { path: string; text: string }[];
count: number;
expected: number;
}

function schemaPaths(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return schemaPaths(path);
}
return entry.name.endsWith(".schema.yaml") ? [path] : [];
});
}

function planSchemaRewrites(
root: string,
from: string,
to: string,
): SchemaRewrites {
const files: { path: string; text: string }[] = [];
let count = 0;
let expected = 0;
for (const path of schemaPaths(join(root, "schemas"))) {
const before = readFileSync(path, "utf8");
const rewritten = rewriteSchemaVersions(before, from, to);
count += rewritten.count;
expected += expectedVersionSites(before);
if (rewritten.text !== before) {
files.push({ path: relative(root, path), text: rewritten.text });
}
}
return { files, count, expected };
}

function writeSchemas(root: string, rewrites: SchemaRewrites): string[] {
for (const { path, text } of rewrites.files) {
writeFileSync(join(root, path), text);
}
return rewrites.files.map(({ path }) => path);
}

// appends every tag it creates to `created`, so a failure partway
// leaves the caller with the exact list to undo
function commitAndTag(
Expand Down Expand Up @@ -636,7 +721,7 @@ export function undoAdvice(created: string[], committed: boolean): string {
if (tags.length > 0) {
return `undo: ${tags}`;
}
return "undo: git checkout HEAD -- packages/*/package.json";
return "undo: git checkout HEAD -- packages/*/package.json schemas/";
}

function report(plan: Move[]): void {
Expand Down Expand Up @@ -681,6 +766,19 @@ export function main(argv: string[]): number {
all,
});
const problems = planProblems(plan, manifests, keyword);
// the schemas ship inside @ethdebug/format, so their examples name
// the version it moves to; when it stays put they are left alone
const specMove = plan.find((move) => move.name === specPackage);
const schemas =
specMove === undefined
? undefined
: planSchemaRewrites(root, specMove.from, specMove.to);
if (schemas !== undefined && schemas.count !== schemas.expected) {
problems.push(
`schemas/: found ${schemas.count} version literals, ` +
`expected ${schemas.expected}`,
);
}
if (problems.length > 0) {
for (const problem of problems) {
console.error(problem);
Expand All @@ -700,6 +798,10 @@ export function main(argv: string[]): number {
}
console.log(`${keyword}: ${plan.length} workspace(s) move`);
report(plan);
if (specMove !== undefined && schemas !== undefined) {
const literals = `${schemas.count} version literals`;
console.log(` schemas: ${literals} -> ${specMove.to}`);
}

const changelogs = changelogProblems(
requiredChangelogs(plan, manifests, root).map(({ path, version }) => ({
Expand Down Expand Up @@ -729,9 +831,15 @@ export function main(argv: string[]): number {
// every release
const headBefore = git(root, ["rev-parse", "HEAD"]);
let written: string[] = [];
let schemaCount = 0;
const created: string[] = [];
try {
written = writeManifests(root, manifests, plan);
if (schemas !== undefined) {
const files = writeSchemas(root, schemas);
schemaCount = files.length;
written.push(...files);
}
commitAndTag(root, written, plan, created);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand All @@ -742,7 +850,12 @@ export function main(argv: string[]): number {
}
console.log(`tagged: ${created.join(", ")}`);
if (written.length > 0) {
console.log(`committed Publish with ${written.length} manifest(s)`);
const schemaNote =
schemaCount > 0 ? ` and ${schemaCount} schema file(s)` : "";
console.log(
`committed Publish with ${written.length - schemaCount} ` +
`manifest(s)${schemaNote}`,
);
console.log("next: git push --atomic origin main --follow-tags");
return 0;
}
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default tseslint.config(
"**/*.config.js",
"**/*.config.ts",
"packages/format/src/schemas/yamls.ts",
"packages/format/src/version.ts",
"packages/bugc/src/examples/generated.ts",
"packages/web/.docusaurus/",
"packages/web/build/",
Expand Down
Loading
Loading